diff --git a/.gitignore b/.gitignore index bf1aff01..b90bb658 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ vue-widgets/dist/ # Working/research notes (not committed) .docs/ + +# HF enrichment validation baseline snapshots (contain potentially +# NSFW README content fetched from community model repos) +tests/enrich_hf_validation/baselines/ diff --git a/docs/agent_skills.md b/docs/agent_skills.md new file mode 100644 index 00000000..a1850e12 --- /dev/null +++ b/docs/agent_skills.md @@ -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.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` | diff --git a/locales/de.json b/locales/de.json index a5bacbea..6c0c4aa5 100644 --- a/locales/de.json +++ b/locales/de.json @@ -657,6 +657,32 @@ "proxyPassword": "Passwort (optional)", "proxyPasswordPlaceholder": "passwort", "proxyPasswordHelp": "Passwort für die Proxy-Authentifizierung (falls erforderlich)" + }, + "aiProvider": { + "title": "KI-Anbieter", + "provider": "Anbieter", + "providerHelp": "Wählen Sie Ihren LLM-Anbieter. OpenAI und Ollama verwenden voreingestellte API-Endpunkte. Mit \"Benutzerdefiniert\" können Sie jeden OpenAI-kompatiblen Endpunkt angeben.", + "providerOptions": { + "openai": "OpenAI", + "ollama": "Ollama (lokal)", + "deepseek": "DeepSeek", + "groq": "Groq", + "openrouter": "OpenRouter", + "opencode-go": "OpenCode Go", + "custom": "Benutzerdefiniert (OpenAI-kompatibel)" + }, + "apiBase": "API-Basis-URL", + "apiBaseHelp": "Die Basis-URL für die LLM-API (z.B. https://api.openai.com/v1). Leer lassen, um die Anbietervoreinstellung zu verwenden.", + "apiBasePlaceholder": "https://api.openai.com/v1", + "apiKey": "API-Schlüssel", + "apiKeyHelp": "Ihr LLM-API-Schlüssel. Wird lokal gespeichert und niemals an einen anderen Server außer Ihrem gewählten LLM-Anbieter gesendet.", + "apiKeyPlaceholder": "sk-...", + "apiKeyNotSet": "Nicht festgelegt", + "apiKeyConfigured": "Konfiguriert", + "apiKeySet": "Einrichten", + "model": "Modell", + "modelHelp": "Der zu verwendende Modellname (z.B. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Prüfen Sie Ihren Anbieter auf verfügbare Modelle.", + "modelPlaceholder": "Modell auswählen..." } }, "loras": { @@ -754,7 +780,8 @@ "completed": "Abgeschlossen: {success} verschoben, {skipped} übersprungen, {failures} fehlgeschlagen", "complete": "Automatische Organisation abgeschlossen", "error": "Fehler: {error}" - } + }, + "enrichHfAgent": "HF-Metadaten mit KI anreichern" }, "contextMenu": { "refreshMetadata": "Civitai-Daten aktualisieren", @@ -778,7 +805,8 @@ "shareRecipe": "Rezept teilen", "viewAllLoras": "Alle LoRAs anzeigen", "downloadMissingLoras": "Fehlende LoRAs herunterladen", - "deleteRecipe": "Rezept löschen" + "deleteRecipe": "Rezept löschen", + "enrichHfAgent": "HF-Metadaten mit KI anreichern" } }, "recipes": { @@ -2081,6 +2109,12 @@ "moveFailed": "Failed to move item: {message}", "copiedToClipboard": "In die Zwischenablage kopiert", "downloadStarted": "Download gestartet" + }, + "agent": { + "llmNotConfigured": "KI-Anbieter nicht konfiguriert. Aktivieren Sie ihn unter Einstellungen → KI-Anbieter.", + "enrichStarted": "Metadaten werden mit KI angereichert...", + "enrichComplete": "Metadatenanreicherung abgeschlossen: {{summary}}", + "enrichFailed": "Metadatenanreicherung fehlgeschlagen: {{error}}" } }, "doctor": { diff --git a/locales/en.json b/locales/en.json index 4d02b9fe..f076cbd7 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,2178 +1,2212 @@ { - "common": { - "cancel": "Cancel", - "confirm": "Confirm", - "actions": { - "save": "Save", - "cancel": "Cancel", - "confirm": "Confirm", - "delete": "Delete", - "move": "Move", - "refresh": "Refresh", - "back": "Back", - "next": "Next", - "backToTop": "Back to top", - "settings": "Settings", - "help": "Help", - "add": "Add", - "close": "Close", - "menu": "Menu", - "remove": "Remove", - "change": "Change" - }, - "status": { - "loading": "Loading...", - "cancelling": "Cancelling...", - "unknown": "Unknown", - "date": "Date", - "version": "Version", - "enabled": "Enabled", - "disabled": "Disabled" - }, - "language": { - "select": "Language", - "select_help": "Choose your preferred language for the interface", - "english": "English", - "chinese_simplified": "中文(简体)", - "chinese_traditional": "中文(繁体)", - "russian": "Русский", - "german": "Deutsch", - "japanese": "日本語", - "korean": "한국어", - "french": "Français", - "spanish": "Español", - "Hebrew": "עברית" - }, - "fileSize": { - "zero": "0 Bytes", - "bytes": "Bytes", - "kb": "KB", - "mb": "MB", - "gb": "GB", - "tb": "TB" - } + "common": { + "cancel": "Cancel", + "confirm": "Confirm", + "actions": { + "save": "Save", + "cancel": "Cancel", + "confirm": "Confirm", + "delete": "Delete", + "move": "Move", + "refresh": "Refresh", + "back": "Back", + "next": "Next", + "backToTop": "Back to top", + "settings": "Settings", + "help": "Help", + "add": "Add", + "close": "Close", + "menu": "Menu", + "remove": "Remove", + "change": "Change" }, - "onboarding": { - "languageSelection": { - "title": "Welcome to LoRA Manager", - "continue": "Continue", - "changeFailed": "Failed to change language: {message}" - }, - "tutorial": { - "skipTutorial": "Skip Tutorial", - "back": "Back", - "next": "Next", - "finish": "Finish" - }, - "steps": { - "fetch": { - "title": "Fetch Models Metadata", - "content": "Click the Fetch button to download model metadata and preview images from Civitai." - }, - "download": { - "title": "Download New Models", - "content": "Use the Download button to download models directly from Civitai URLs." - }, - "bulk": { - "title": "Bulk Operations", - "content": "Enter bulk mode by clicking this button or pressing B. Select multiple models and perform batch operations. Use Ctrl+A to select all visible models." - }, - "searchOptions": { - "title": "Search Options", - "content": "Click this button to configure what fields to search in: filename, model name, tags, or creator name. Customize your search scope." - }, - "filter": { - "title": "Filter Models", - "content": "Use filters to narrow down models by base model type (SD1.5, SDXL, Flux, etc.) or by specific tags." - }, - "breadcrumb": { - "title": "Breadcrumb Navigation", - "content": "The breadcrumb navigation shows your current path and allows quick navigation between folders. Click any folder name to jump directly there." - }, - "modelCards": { - "title": "Model Cards", - "content": "Single-click a model card to view detailed information and edit metadata. Look for the pencil icon when hovering over editable fields." - }, - "contextMenu": { - "title": "Context Menu", - "content": "Right-click any model card for a context menu with additional actions." - } - } + "status": { + "loading": "Loading...", + "cancelling": "Cancelling...", + "unknown": "Unknown", + "date": "Date", + "version": "Version", + "enabled": "Enabled", + "disabled": "Disabled" }, - "modelCard": { - "actions": { - "addToFavorites": "Add to favorites", - "removeFromFavorites": "Remove from favorites", - "viewOnCivitai": "View on Civitai", - "notAvailableFromCivitai": "Not available from Civitai", - "viewOnHuggingFace": "View on Hugging Face", - "sendToWorkflow": "Send to ComfyUI (Click: Append, Shift+Click: Replace)", - "copyLoRASyntax": "Copy LoRA Syntax", - "checkpointNameCopied": "Checkpoint name copied", - "toggleBlur": "Toggle blur", - "show": "Show", - "openExampleImages": "Open Example Images Folder", - "replacePreview": "Replace Preview", - "copyCheckpointName": "Copy checkpoint name", - "copyEmbeddingName": "Copy embedding name", - "embeddingNameCopied": "Embedding syntax copied", - "sendCheckpointToWorkflow": "Send to ComfyUI", - "sendEmbeddingToWorkflow": "Send to ComfyUI" - }, - "nsfw": { - "matureContent": "Mature Content", - "xxxRated": "XXX-rated Content", - "xRated": "X-rated Content", - "rRated": "R-rated Content" - }, - "favorites": { - "added": "Added to favorites", - "removed": "Removed from favorites", - "updateFailed": "Failed to update favorite status" - }, - "sendToWorkflow": { - "checkpointNotImplemented": "Send checkpoint to workflow - feature to be implemented", - "missingPath": "Unable to determine model path for this card" - }, - "exampleImages": { - "checkError": "Error checking for example images", - "missingHash": "Missing model hash information.", - "noRemoteImagesAvailable": "No remote example images available for this model on Civitai" - }, - "badges": { - "update": "Update", - "updateAvailable": "Update available", - "skipRefresh": "Metadata refresh skipped" - }, - "usage": { - "timesUsed": "Times used" - }, - "footer": { - "versionCount": "{count} versions", - "viewAllVersions": "View all local versions" - } + "language": { + "select": "Language", + "select_help": "Choose your preferred language for the interface", + "english": "English", + "chinese_simplified": "中文(简体)", + "chinese_traditional": "中文(繁体)", + "russian": "Русский", + "german": "Deutsch", + "japanese": "日本語", + "korean": "한국어", + "french": "Français", + "spanish": "Español", + "Hebrew": "עברית" }, - "globalContextMenu": { - "downloadExampleImages": { - "label": "Download example images", - "missingPath": "Set a download location before downloading example images.", - "unavailable": "Example image downloads aren't available yet. Try again after the page finishes loading." - }, - "checkModelUpdates": { - "label": "Check for updates", - "loading": "Checking for {type} updates...", - "success": "Found {count} update(s) for {type}s", - "none": "All {type}s are up to date", - "error": "Failed to check for {type} updates: {message}" - }, - "cleanupExampleImages": { - "label": "Clean up example image folders", - "success": "Moved {count} folder(s) to the deleted folder", - "none": "No example image folders needed cleanup", - "partial": "Cleanup completed with {failures} folder(s) skipped", - "error": "Failed to clean example image folders: {message}" - }, - "fetchMissingLicenses": { - "label": "Refresh license metadata", - "loading": "Refreshing license metadata for {typePlural}...", - "success": "Updated license metadata for {count} {typePlural}", - "none": "All {typePlural} already have license metadata", - "error": "Failed to refresh license metadata for {typePlural}: {message}" - }, - "repairRecipes": { - "label": "Repair recipes data", - "loading": "Repairing recipe data...", - "success": "Successfully repaired {count} recipes.", - "cancelled": "Repair cancelled. {count} recipes were repaired.", - "error": "Recipe repair failed: {message}" - }, - "manageExcludedModels": { - "label": "Manage Excluded Models" - }, - "groupByModel": { - "label": "Group by Model" - } + "fileSize": { + "zero": "0 Bytes", + "bytes": "Bytes", + "kb": "KB", + "mb": "MB", + "gb": "GB", + "tb": "TB" + } + }, + "onboarding": { + "languageSelection": { + "title": "Welcome to LoRA Manager", + "continue": "Continue", + "changeFailed": "Failed to change language: {message}" }, - "header": { - "appTitle": "LoRA Manager", - "navigation": { - "loras": "LoRAs", - "recipes": "Recipes", - "checkpoints": "Checkpoints", - "embeddings": "Embeddings", - "statistics": "Stats" - }, - "search": { - "placeholder": "Search", - "options": "Search Options", - "searchIn": "Search In:", - "notAvailable": "Search not available on statistics page", - "filters": { - "filename": "Filename", - "modelname": "Model Name", - "tags": "Tags", - "creator": "Creator", - "title": "Recipe Title", - "loraName": "LoRA Filename", - "loraModel": "LoRA Model Name", - "prompt": "Prompt" - } - }, - "filter": { - "title": "Filter Models", - "presets": "Presets", - "savePreset": "Save current active filters as a new preset.", - "savePresetDisabledActive": "Cannot save: A preset is already active. Modify filters to save new preset.", - "savePresetDisabledNoFilters": "Select filters first to save as preset", - "savePresetPrompt": "Enter preset name:", - "presetClickTooltip": "Click to apply preset \"{name}\"", - "presetDeleteTooltip": "Delete preset", - "presetDeleteConfirm": "Delete preset \"{name}\"?", - "presetDeleteConfirmClick": "Click again to confirm", - "presetOverwriteConfirm": "Preset \"{name}\" already exists. Overwrite?", - "presetNamePlaceholder": "Preset name...", - "baseModel": "Base Model", - "baseModelSearchPlaceholder": "Search base models...", - "modelTags": "Tags (Top 20)", - "modelTypes": "Model Types", - "license": "License", - "noCreditRequired": "No Credit Required", - "allowSellingGeneratedContent": "Allow Selling", - "allowSellingGeneratedContentTooltip": "Allow selling generated images", - "noCreditRequiredTooltip": "Use the model without crediting the creator", - "noTags": "No tags", - "autoTags": "Auto Tags", - "noBaseModelMatches": "No base models match the current search.", - "clearAll": "Clear All Filters", - "any": "Any", - "all": "All", - "tagLogicAny": "Match any tag (OR)", - "tagLogicAll": "Match all tags (AND)" - }, - "theme": { - "toggle": "Toggle theme", - "switchToLight": "Switch to light theme", - "switchToDark": "Switch to dark theme", - "switchToAuto": "Switch to auto theme", - "presets": "Theme Presets", - "default": "Default", - "nord": "Nord", - "midnight": "Midnight", - "monokai": "Monokai", - "dracula": "Dracula", - "solarized": "Solarized", - "mode": "Mode", - "light": "Light", - "dark": "Dark", - "auto": "Auto" - }, - "actions": { - "checkUpdates": "Check Updates", - "notifications": "Notifications", - "support": "Support" - } + "tutorial": { + "skipTutorial": "Skip Tutorial", + "back": "Back", + "next": "Next", + "finish": "Finish" }, - "settings": { - "civitaiApiKey": "Civitai API Key", - "civitaiApiKeyPlaceholder": "Enter your Civitai API key", - "civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai", - "civitaiApiKeyConfigured": "Configured", - "civitaiApiKeyNotConfigured": "Not configured", - "civitaiApiKeySet": "Set up", - "civitaiHost": { - "label": "Civitai host", - "help": "Choose which Civitai site opens when using View on Civitai links.", - "options": { - "com": "civitai.com (SFW)", - "red": "civitai.red (unrestricted)" - } - }, - "downloadBackend": { - "label": "Download backend", - "help": "Choose how model files are downloaded. Python uses the built-in downloader. aria2 uses the recommended external downloader process.", - "options": { - "python": "Python (built-in)", - "aria2": "aria2 (recommended)" - } - }, - "aria2cPath": { - "label": "aria2c path", - "help": "Optional path to the aria2c executable. Leave empty to use aria2c from your system PATH.", - "placeholder": "Leave empty to use aria2c from PATH" - }, - "aria2HelpLink": "Learn how to set up the aria2 download backend", - "civitaiHostBanner": { - "title": "Civitai host preference available", - "content": "Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.", - "openSettings": "Open Settings" - }, - "openSettingsFileLocation": { - "label": "Open settings folder", - "tooltip": "Open folder containing settings.json", - "success": "Opened settings.json folder", - "failed": "Failed to open settings.json folder", - "copied": "Settings path copied to clipboard: {{path}}", - "clipboardFallback": "Settings path: {{path}}" - }, - "sections": { - "contentFiltering": "Content Filtering", - "downloads": "Downloads", - "videoSettings": "Video Settings", - "layoutSettings": "Layout Settings", - "licenseIcons": "License Icons", - "misc": "Miscellaneous", - "backup": "Backups", - "folderSettings": "Default Roots", - "recipeSettings": "Recipes", - "extraFolderPaths": "Extra Folder Paths", - "downloadPathTemplates": "Download Path Templates", - "priorityTags": "Priority Tags", - "versionScope": "Version Scope", - "exampleImages": "Example Images", - "autoOrganize": "Auto-organize", - "metadata": "Metadata", - "proxySettings": "Proxy Settings" - }, - "nav": { - "general": "General", - "interface": "Interface", - "library": "Library" - }, - "search": { - "placeholder": "Search settings...", - "clear": "Clear search", - "noResults": "No settings found matching \"{query}\"" - }, - "storage": { - "locationLabel": "Portable mode", - "locationHelp": "Enable to keep settings.json inside the repository; disable to store it in your user config directory." - }, - "contentFiltering": { - "blurNsfwContent": "Blur NSFW Content", - "blurNsfwContentHelp": "Blur mature (NSFW) content preview images", - "showOnlySfw": "Show Only SFW Results", - "showOnlySfwHelp": "Filter out all NSFW content when browsing and searching", - "matureBlurThreshold": "Mature Blur Threshold", - "matureBlurThresholdHelp": "Set which rating level starts blur filtering when NSFW blur is enabled.", - "matureBlurThresholdOptions": { - "pg13": "PG13 and above", - "r": "R and above (default)", - "x": "X and above", - "xxx": "XXX only" - } - }, - "videoSettings": { - "autoplayOnHover": "Autoplay Videos on Hover", - "autoplayOnHoverHelp": "Only play video previews when hovering over them" - }, - "autoOrganizeExclusions": { - "label": "Auto-organize exclusions", - "placeholder": "Example: curated/*, */backups/*; *_temp.safetensors", - "help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.", - "validation": { - "noPatterns": "Enter at least one pattern separated by commas or semicolons.", - "saveFailed": "Unable to save exclusions: {message}" - } - }, - "metadataRefreshSkipPaths": { - "label": "Metadata refresh skip paths", - "placeholder": "Example: temp, archived/old, test_models", - "help": "Skip models in these directory paths during bulk metadata refresh (\"Fetch All Metadata\"). Enter folder paths relative to your model root directory, separated by commas.", - "validation": { - "noPaths": "Enter at least one path separated by commas.", - "saveFailed": "Unable to save skip paths: {message}" - } - }, - "backup": { - "autoEnabled": "Automatic backups", - "autoEnabledHelp": "Create a local snapshot once per day and keep the latest snapshots according to the retention policy.", - "retention": "Retention count", - "retentionHelp": "How many automatic snapshots to keep before older ones are pruned.", - "management": "Backup management", - "managementHelp": "Export your current user state or restore it from a backup archive.", - "scopeHelp": "Backs up your settings, download history, and model update state. It does not include model files or rebuildable caches.", - "locationSummary": "Current backup location", - "openFolderButton": "Open backup folder", - "openFolderSuccess": "Opened backup folder", - "openFolderFailed": "Failed to open backup folder", - "locationCopied": "Backup path copied to clipboard: {{path}}", - "locationClipboardFallback": "Backup path: {{path}}", - "exportButton": "Export backup", - "exportSuccess": "Backup exported successfully.", - "exportFailed": "Failed to export backup: {message}", - "importButton": "Import backup", - "importConfirm": "Import this backup and overwrite local user state?", - "importSuccess": "Backup imported successfully.", - "importFailed": "Failed to import backup: {message}", - "latestSnapshot": "Latest snapshot", - "latestAutoSnapshot": "Latest automatic snapshot", - "snapshotCount": "Saved snapshots", - "noneAvailable": "No snapshots yet" - }, - "downloadSkipBaseModels": { - "label": "Skip downloads for base models", - "help": "When enabled, versions using the selected base models will be skipped.", - "searchPlaceholder": "Filter base models...", - "empty": "No base models match the current search.", - "summary": { - "none": "None selected", - "count": "{count} selected" - }, - "actions": { - "edit": "Edit", - "collapse": "Collapse", - "clear": "Clear" - }, - "validation": { - "saveFailed": "Unable to save excluded base models: {message}" - } - }, - "skipPreviouslyDownloadedModelVersions": { - "label": "Skip previously downloaded model versions", - "help": "When enabled, versions downloaded before will be skipped." - }, - "layoutSettings": { - "groupByModel": "Group by Model", - "groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.", - "displayDensity": "Display Density", - "displayDensityOptions": { - "default": "Default", - "medium": "Medium", - "compact": "Compact" - }, - "displayDensityHelp": "Choose how many cards to display per row:", - "displayDensityDetails": { - "default": "5 (1080p), 6 (2K), 8 (4K)", - "medium": "6 (1080p), 7 (2K), 9 (4K)", - "compact": "7 (1080p), 8 (2K), 10 (4K)" - }, - "displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.", - "showFolderSidebar": "Show Folder Sidebar", - "showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.", - "cardInfoDisplay": "Card Info Display", - "cardInfoDisplayOptions": { - "always": "Always Visible", - "hover": "Reveal on Hover" - }, - "cardInfoDisplayHelp": "Choose when to display model information and action buttons", - "showVersionOnCard": "Show Version on Card", - "showVersionOnCardHelp": "Show or hide the version name on model cards", - "modelCardFooterAction": "Model Card Button Action", - "modelCardFooterActionOptions": { - "exampleImages": "Open Example Images", - "replacePreview": "Replace Preview" - }, - "modelCardFooterActionHelp": "Choose what the bottom-right card button does", - "modelNameDisplay": "Model Name Display", - "modelNameDisplayOptions": { - "modelName": "Model Name", - "fileName": "File Name" - }, - "modelNameDisplayHelp": "Choose what to display in the model card footer", - "cardBlurAmount": "Card Overlay Blur", - "cardBlurAmountHelp": "Adjust the blur intensity of the header and footer overlays on model and recipe cards (0 = no blur, 20 = maximum blur)." - }, - "folderSettings": { - "activeLibrary": "Active Library", - "activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.", - "loadingLibraries": "Loading libraries...", - "noLibraries": "No libraries configured", - "defaultLoraRoot": "LoRA Root", - "defaultLoraRootHelp": "Set default LoRA root directory for downloads, imports and moves", - "defaultCheckpointRoot": "Checkpoint Root", - "defaultCheckpointRootHelp": "Set default checkpoint root directory for downloads, imports and moves", - "defaultUnetRoot": "Diffusion Model Root", - "defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves", - "defaultEmbeddingRoot": "Embedding Root", - "defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves", - "recipesPath": "Recipes Storage Path", - "recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.", - "recipesPathPlaceholder": "/path/to/recipes", - "recipesPathMigrating": "Migrating recipes storage...", - "noDefault": "No Default" - }, - "extraFolderPaths": { - "title": "Extra Folder Paths", - "description": "Additional model root paths exclusive to LoRA Manager. Load models from locations outside ComfyUI's standard folders—ideal for large libraries that would otherwise slow down ComfyUI.", - "restartRequired": "Requires restart to take effect", - "modelTypes": { - "lora": "LoRA Paths", - "checkpoint": "Checkpoint Paths", - "unet": "Diffusion Model Paths", - "embedding": "Embedding Paths" - }, - "pathPlaceholder": "/path/to/extra/models", - "saveSuccess": "Extra folder paths updated. Restart required to apply changes.", - "saveError": "Failed to update extra folder paths: {message}", - "validation": { - "duplicatePath": "This path is already configured" - } - }, - "priorityTags": { - "title": "Priority Tags", - "description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))", - "placeholder": "character, concept, style(toon|toon_style)", - "helpLinkLabel": "Open priority tags help", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "Checkpoint", - "embedding": "Embedding" - }, - "saveSuccess": "Priority tags updated.", - "saveError": "Failed to update priority tags.", - "loadingSuggestions": "Loading suggestions...", - "validation": { - "missingClosingParen": "Entry {index} is missing a closing parenthesis.", - "missingCanonical": "Entry {index} must include a canonical tag name.", - "duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.", - "unknown": "Invalid priority tag configuration." - } - }, - "downloadPathTemplates": { - "title": "Download Path Templates", - "help": "Configure folder structures for different model types when downloading from Civitai.", - "availablePlaceholders": "Available placeholders:", - "templateOptions": { - "flatStructure": "Flat Structure", - "byBaseModel": "By Base Model", - "byAuthor": "By Author", - "byFirstTag": "By First Tag", - "baseModelFirstTag": "Base Model + First Tag", - "baseModelAuthor": "Base Model + Author", - "authorFirstTag": "Author + First Tag", - "baseModelAuthorFirstTag": "Base Model + Author + First Tag", - "customTemplate": "Custom Template" - }, - "customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "Checkpoint", - "embedding": "Embedding" - }, - "baseModelPathMappings": "Base Model Path Mappings", - "baseModelPathMappingsHelp": "Customize folder names for specific base models (e.g., \"Flux.1 D\" → \"flux\")", - "addMapping": "Add Mapping", - "selectBaseModel": "Select Base Model", - "customPathPlaceholder": "Custom path (e.g., flux)", - "removeMapping": "Remove mapping", - "validation": { - "validFlat": "Valid (flat structure)", - "invalidChars": "Invalid characters detected", - "doubleSlashes": "Double slashes not allowed", - "leadingTrailingSlash": "Cannot start or end with slash", - "invalidPlaceholder": "Invalid placeholder: {placeholder}", - "validTemplate": "Valid template" - } - }, - "exampleImages": { - "downloadLocation": "Download Location", - "downloadLocationPlaceholder": "Enter folder path for example images", - "downloadLocationHelp": "Enter the folder path where example images from Civitai will be saved", - "autoDownload": "Auto Download Example Images", - "autoDownloadHelp": "Automatically download example images for models that don't have them (requires download location to be set)", - "openMode": "Open Example Images Action", - "openModeHelp": "Choose whether the action opens on the server, copies a mapped local path, or launches a custom URI.", - "openModeOptions": { - "system": "Open on server", - "clipboard": "Copy local path", - "uriTemplate": "Open custom URI" - }, - "localRoot": "Local Example Images Root", - "localRootHelp": "Optional local or mounted root that mirrors the server example images directory. If blank, the server path is reused.", - "localRootPlaceholder": "Example: /Volumes/ComfyUI/example_images", - "uriTemplate": "Open URI Template", - "uriTemplateHelp": "Use a custom deep link such as a file URI or a Shortcuts link.", - "uriTemplatePlaceholder": "Example: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}", - "uriTemplatePlaceholders": "Available placeholders: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}", - "openModeWikiLink": "Learn more about remote open modes", - "optimizeImages": "Optimize Downloaded Images", - "optimizeImagesHelp": "Optimize example images to reduce file size and improve loading speed (metadata will be preserved)", - "download": "Download", - "restartRequired": "Requires restart" - }, - "versionGrouping": { - "label": "Version Grouping", - "help": "Decide how versions are grouped for display: by base model or all together. Also controls update badge logic and the VLM version list filtering.", - "options": { - "sameBase": "Group by base model (same_base)", - "any": "Show all versions (any)" - } - }, - "hideEarlyAccessUpdates": { - "label": "Hide Early Access Updates", - "help": "When enabled, models with only early access updates will not show 'Update available' badge" - }, - "licenseIcons": { - "useNewStyle": "Use updated license icons", - "useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design." - }, - "misc": { - "includeTriggerWords": "Include Trigger Words in LoRA Syntax", - "includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard", - "loraSyntaxFormat": "LoRA Syntax Format", - "loraSyntaxFormatHelp": "LoRA syntax format. Full includes subfolder path () for lossless model resolution. Legacy uses filename only () — A1111 convention, may be ambiguous with duplicate filenames across folders.", - "loraSyntaxFormatOptions": { - "full": "Full path (subfolder/name)", - "legacy": "Legacy A1111 (name only)" - } - }, - "metadataArchive": { - "enableArchiveDb": "Enable Metadata Archive Database", - "enableArchiveDbHelp": "Use a local database to access metadata for models that have been deleted from Civitai.", - "status": "Status", - "statusAvailable": "Available", - "statusUnavailable": "Not Available", - "enabled": "Enabled", - "management": "Database Management", - "managementHelp": "Download or remove the metadata archive database", - "downloadButton": "Download Database", - "downloadingButton": "Downloading...", - "downloadedButton": "Downloaded", - "removeButton": "Remove Database", - "removingButton": "Removing...", - "downloadSuccess": "Metadata archive database downloaded successfully", - "downloadError": "Failed to download metadata archive database", - "removeSuccess": "Metadata archive database removed successfully", - "removeError": "Failed to remove metadata archive database", - "removeConfirm": "Are you sure you want to remove the metadata archive database? This will delete the local database file and you'll need to download it again to use this feature.", - "preparing": "Preparing download...", - "connecting": "Connecting to download server...", - "completed": "Completed", - "downloadComplete": "Download completed successfully" - }, - "proxySettings": { - "enableProxy": "Enable App-level Proxy", - "enableProxyHelp": "Enable custom proxy settings for this application, overriding system proxy settings", - "proxyType": "Proxy Type", - "proxyTypeHelp": "Select the type of proxy server (HTTP, HTTPS, SOCKS4, SOCKS5)", - "proxyHost": "Proxy Host", - "proxyHostPlaceholder": "proxy.example.com", - "proxyHostHelp": "The hostname or IP address of your proxy server", - "proxyPort": "Proxy Port", - "proxyPortPlaceholder": "8080", - "proxyPortHelp": "The port number of your proxy server", - "proxyUsername": "Username (Optional)", - "proxyUsernamePlaceholder": "username", - "proxyUsernameHelp": "Username for proxy authentication (if required)", - "proxyPassword": "Password (Optional)", - "proxyPasswordPlaceholder": "password", - "proxyPasswordHelp": "Password for proxy authentication (if required)" - } + "steps": { + "fetch": { + "title": "Fetch Models Metadata", + "content": "Click the Fetch button to download model metadata and preview images from Civitai." + }, + "download": { + "title": "Download New Models", + "content": "Use the Download button to download models directly from Civitai URLs." + }, + "bulk": { + "title": "Bulk Operations", + "content": "Enter bulk mode by clicking this button or pressing B. Select multiple models and perform batch operations. Use Ctrl+A to select all visible models." + }, + "searchOptions": { + "title": "Search Options", + "content": "Click this button to configure what fields to search in: filename, model name, tags, or creator name. Customize your search scope." + }, + "filter": { + "title": "Filter Models", + "content": "Use filters to narrow down models by base model type (SD1.5, SDXL, Flux, etc.) or by specific tags." + }, + "breadcrumb": { + "title": "Breadcrumb Navigation", + "content": "The breadcrumb navigation shows your current path and allows quick navigation between folders. Click any folder name to jump directly there." + }, + "modelCards": { + "title": "Model Cards", + "content": "Single-click a model card to view detailed information and edit metadata. Look for the pencil icon when hovering over editable fields." + }, + "contextMenu": { + "title": "Context Menu", + "content": "Right-click any model card for a context menu with additional actions." + } + } + }, + "modelCard": { + "actions": { + "addToFavorites": "Add to favorites", + "removeFromFavorites": "Remove from favorites", + "viewOnCivitai": "View on Civitai", + "notAvailableFromCivitai": "Not available from Civitai", + "viewOnHuggingFace": "View on Hugging Face", + "sendToWorkflow": "Send to ComfyUI (Click: Append, Shift+Click: Replace)", + "copyLoRASyntax": "Copy LoRA Syntax", + "checkpointNameCopied": "Checkpoint name copied", + "toggleBlur": "Toggle blur", + "show": "Show", + "openExampleImages": "Open Example Images Folder", + "replacePreview": "Replace Preview", + "copyCheckpointName": "Copy checkpoint name", + "copyEmbeddingName": "Copy embedding name", + "embeddingNameCopied": "Embedding syntax copied", + "sendCheckpointToWorkflow": "Send to ComfyUI", + "sendEmbeddingToWorkflow": "Send to ComfyUI" }, - "loras": { - "controls": { - "sort": { - "title": "Sort models by...", - "name": "Name", - "nameAsc": "A - Z", - "nameDesc": "Z - A", - "date": "Date Added", - "dateDesc": "Newest", - "dateAsc": "Oldest", - "size": "File Size", - "sizeDesc": "Largest", - "sizeAsc": "Smallest", - "usage": "Use Count", - "usageDesc": "Most", - "usageAsc": "Least", - "versionsCount": "Local Versions", - "versionsCountDesc": "Most versions first", - "versionsCountAsc": "Fewest versions first", - "versionIdDesc": "Newest version first" - }, - "refresh": { - "title": "Refresh model list", - "full": "Rebuild Cache", - "fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits." - }, - "fetch": { - "title": "Fetch metadata from Civitai", - "action": "Fetch" - }, - "download": { - "title": "Download from URL", - "action": "Download" - }, - "bulk": { - "title": "Bulk Operations", - "action": "Bulk" - }, - "duplicates": { - "title": "Find Duplicates", - "action": "Duplicates" - }, - "favorites": { - "title": "Show Favorites Only", - "action": "Favorites" - }, - "updates": { - "title": "Show models with updates available", - "action": "Updates", - "menuLabel": "Show update options", - "check": "Check updates", - "checkTooltip": "Checking updates may take a while." - } - }, - "bulkOperations": { - "selected": "{count} selected", - "selectedSuffix": "selected", - "viewSelected": "View Selected", - "addTags": "Add Tags to Selected", - "setBaseModel": "Set Base Model for Selected", - "setContentRating": "Set Content Rating for Selected", - "copyAll": "Copy Selected Syntax", - "refreshAll": "Refresh Selected Metadata", - "repairMetadata": "Repair Metadata for Selected", - "reimportMetadata": "Re-import from Source", - "checkUpdates": "Check Updates for Selected", - "moveAll": "Move Selected to Folder", - "autoOrganize": "Auto-Organize Selected", - "skipMetadataRefresh": "Skip Metadata Refresh for Selected", - "resumeMetadataRefresh": "Resume Metadata Refresh for Selected", - "setFavorite": "Set as Favorite", - "setFavoriteCount": "Set as Favorite ({favorited}/{total})", - "unfavorite": "Remove from Favorites", - "deleteAll": "Delete Selected", - "downloadMissingLoras": "Download Missing LoRAs", - "downloadExamples": "Download Example Images", - "clear": "Clear Selection", - "skipMetadataRefreshCount": "Skip ({count} models)", - "resumeMetadataRefreshCount": "Resume ({count} models)", - "sendToWorkflow": "Send to Workflow", - "sections": { - "workflow": "Workflow", - "metadata": "Metadata", - "attributes": "Attributes", - "organize": "Organize", - "download": "Download" - }, - "autoOrganizeProgress": { - "initializing": "Initializing auto-organize...", - "starting": "Starting auto-organize for {type}...", - "processing": "Processing ({processed}/{total}) - {success} moved, {skipped} skipped, {failures} failed", - "cleaning": "Cleaning up empty directories...", - "completed": "Completed: {success} moved, {skipped} skipped, {failures} failed", - "complete": "Auto-organize complete", - "error": "Error: {error}" - } - }, - "contextMenu": { - "refreshMetadata": "Refresh Civitai Data", - "checkUpdates": "Check Updates", - "relinkCivitai": "Re-link to Civitai", - "copySyntax": "Copy LoRA Syntax", - "copyFilename": "Copy Model Filename", - "copyRecipeSyntax": "Copy Recipe Syntax", - "sendToWorkflowAppend": "Send to Workflow (Append)", - "sendToWorkflowReplace": "Send to Workflow (Replace)", - "openExamples": "Open Examples Folder", - "downloadExamples": "Download Example Images", - "replacePreview": "Replace Preview", - "setContentRating": "Set Content Rating", - "moveToFolder": "Move to Folder", - "repairMetadata": "Repair metadata", - "reimportMetadata": "Re-import from Source", - "excludeModel": "Exclude Model", - "restoreModel": "Restore Model", - "deleteModel": "Delete Model", - "shareRecipe": "Share Recipe", - "viewAllLoras": "View All LoRAs", - "downloadMissingLoras": "Download Missing LoRAs", - "deleteRecipe": "Delete Recipe" - } + "nsfw": { + "matureContent": "Mature Content", + "xxxRated": "XXX-rated Content", + "xRated": "X-rated Content", + "rRated": "R-rated Content" }, - "recipes": { - "title": "LoRA Recipes", - "actions": { - "sendCheckpoint": "Send to ComfyUI" - }, - "controls": { - "import": { - "action": "Import", - "title": "Import a recipe from image or URL", - "urlLocalPath": "URL / Local Path", - "uploadImage": "Upload Image", - "urlSectionDescription": "Input a Civitai image URL from civitai.com or civitai.red, or a local file path, to import as a recipe.", - "imageUrlOrPath": "Image URL or File Path:", - "urlPlaceholder": "https://civitai.com/images/... or https://civitai.red/images/... or C:/path/to/image.png", - "fetchImage": "Fetch Image", - "uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.", - "selectImage": "Select Image", - "recipeName": "Recipe Name", - "recipeNamePlaceholder": "Enter recipe name", - "tagsOptional": "Tags (optional)", - "addTagPlaceholder": "Add a tag", - "addTag": "Add", - "noTagsAdded": "No tags added", - "lorasInRecipe": "LoRAs in this Recipe", - "downloadLocationPreview": "Download Location Preview:", - "useDefaultPath": "Use Default Path", - "useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates", - "selectLoraRoot": "Select a LoRA root directory", - "targetFolderPath": "Target Folder Path:", - "folderPathPlaceholder": "Type folder path or select from tree below...", - "createNewFolder": "Create new folder", - "root": "Root", - "browseFolders": "Browse Folders:", - "downloadAndSaveRecipe": "Download & Save Recipe", - "importRecipeOnly": "Import Recipe Only", - "importAndDownload": "Import & Download", - "downloadMissingLoras": "Download Missing LoRAs", - "saveRecipe": "Save Recipe", - "loraCountInfo": "({existing}/{total} in library)", - "processingInput": "Processing input...", - "analyzingMetadata": "Analyzing image metadata...", - "downloadingLoras": "Downloading LoRAs...", - "savingRecipe": "Saving recipe...", - "startingDownload": "Starting download for LoRA {current}/{total}", - "deletedFromCivitai": "Deleted from Civitai", - "inLibrary": "In Library", - "notInLibrary": "Not in Library", - "earlyAccessRequired": "This LoRA requires early access payment to download.", - "earlyAccessEnds": "Early access ends on {date}.", - "earlyAccess": "Early Access", - "verifyEarlyAccess": "Verify that you have purchased early access before downloading.", - "duplicateRecipesFound": "{count} identical recipe(s) found in your library", - "duplicateRecipesDescription": "These recipes contain the same LoRAs with identical weights.", - "showDuplicates": "Show duplicates", - "hideDuplicates": "Hide duplicates", - "loraCount": "{count} LoRAs", - "recipePreviewAlt": "Recipe preview", - "loraPreviewAlt": "LoRA preview", - "errors": { - "selectImageFile": "Please select an image file", - "enterUrlOrPath": "Please enter a URL or file path", - "selectLoraRoot": "Please select a LoRA root directory" - } - }, - "sort": { - "title": "Sort recipes by...", - "name": "Name", - "nameAsc": "A - Z", - "nameDesc": "Z - A", - "date": "Date", - "dateDesc": "Newest", - "dateAsc": "Oldest", - "lorasCount": "LoRA Count", - "lorasCountDesc": "Most", - "lorasCountAsc": "Least" - }, - "refresh": { - "title": "Refresh recipe list", - "full": "Rebuild Cache", - "fullTooltip": "Rebuild cache - full rescan of all recipe files" - }, - "filteredByLora": "Filtered by LoRA", - "favorites": { - "title": "Show Favorites Only", - "action": "Favorites" - } - }, - "duplicates": { - "found": "Found {count} duplicate groups", - "keepLatest": "Keep Latest Versions", - "deleteSelected": "Delete Selected" - }, - "contextMenu": { - "copyRecipe": { - "missingId": "Cannot copy recipe: Missing recipe ID", - "failed": "Failed to copy recipe syntax" - }, - "sendRecipe": { - "missingId": "Cannot send recipe: Missing recipe ID", - "failed": "Failed to send recipe to workflow" - }, - "viewLoras": { - "missingId": "Cannot view LoRAs: Missing recipe ID", - "noLorasFound": "No LoRAs found in this recipe", - "loadError": "Error loading recipe LoRAs: {message}" - }, - "downloadMissing": { - "missingId": "Cannot download LoRAs: Missing recipe ID", - "noMissingLoras": "No missing LoRAs to download", - "getInfoFailed": "Failed to get information for missing LoRAs", - "prepareError": "Error preparing LoRAs for download: {message}" - }, - "repair": { - "starting": "Repairing recipe metadata...", - "success": "Recipe metadata repaired successfully", - "skipped": "Recipe already at latest version, no repair needed", - "failed": "Failed to repair recipe: {message}", - "missingId": "Cannot repair recipe: Missing recipe ID" - }, - "reimport": { - "starting": "Re-importing recipe from source...", - "success": "Recipe re-imported successfully", - "noSourceUrl": "Recipe has no source URL, cannot re-import", - "failed": "Failed to re-import recipe: {message}", - "missingId": "Cannot re-import recipe: Missing recipe ID" - } - }, - "batchImport": { - "title": "Batch Import Recipes", - "action": "Batch Import", - "urlList": "URL List", - "directory": "Directory", - "urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.", - "directoryDescription": "Enter a directory path to import all images from that folder.", - "urlsLabel": "Image URLs or Local Paths", - "urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...", - "urlsHint": "Enter one URL or path per line", - "directoryPath": "Directory Path", - "directoryPlaceholder": "/path/to/images/folder", - "browse": "Browse", - "recursive": "Include subdirectories", - "tagsOptional": "Tags (optional, applied to all recipes)", - "tagsPlaceholder": "Enter tags separated by commas", - "tagsHint": "Tags will be added to all imported recipes", - "skipNoMetadata": "Skip images without metadata", - "skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.", - "start": "Start Import", - "startImport": "Start Import", - "importing": "Importing...", - "progress": "Progress", - "total": "Total", - "success": "Success", - "failed": "Failed", - "skipped": "Skipped", - "current": "Current", - "currentItem": "Current", - "preparing": "Preparing...", - "cancel": "Cancel", - "cancelImport": "Cancel", - "cancelled": "Import cancelled", - "completed": "Import completed", - "completedWithErrors": "Completed with errors", - "completedSuccess": "Successfully imported {count} recipe(s)", - "successCount": "Successful", - "failedCount": "Failed", - "skippedCount": "Skipped", - "totalProcessed": "Total processed", - "viewDetails": "View Details", - "newImport": "New Import", - "manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.", - "batchImportDirectorySelected": "Directory selected: {path}", - "batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.", - "backToParent": "Back to parent directory", - "folders": "Folders", - "folderCount": "{count} folders", - "imageFiles": "Image Files", - "images": "images", - "imageCount": "{count} images", - "selectFolder": "Select This Folder", - "errors": { - "enterUrls": "Please enter at least one URL or path", - "enterDirectory": "Please enter a directory path", - "startFailed": "Failed to start import: {message}" - } - } + "favorites": { + "added": "Added to favorites", + "removed": "Removed from favorites", + "updateFailed": "Failed to update favorite status" }, - "checkpoints": { - "title": "Checkpoint Models", - "modelTypes": { - "checkpoint": "Checkpoint", - "diffusion_model": "Diffusion Model" - }, - "contextMenu": { - "moveToOtherTypeFolder": "Move to {otherType} Folder", - "sendToWorkflow": "Send to Workflow" - } + "sendToWorkflow": { + "checkpointNotImplemented": "Send checkpoint to workflow - feature to be implemented", + "missingPath": "Unable to determine model path for this card" }, - "embeddings": { - "title": "Embedding Models" + "exampleImages": { + "checkError": "Error checking for example images", + "missingHash": "Missing model hash information.", + "noRemoteImagesAvailable": "No remote example images available for this model on Civitai" }, - "sidebar": { - "modelRoot": "Root", - "collapseAll": "Collapse All Folders", - "hideOnThisPage": "Hide sidebar on this page", - "showSidebar": "Show sidebar", - "sidebarHiddenNotification": "Folder sidebar hidden on {page} page", - "switchToListView": "Switch to List View", - "switchToTreeView": "Switch to Tree View", - "recursiveOn": "Include subfolders", - "recursiveOff": "Current folder only", - "recursiveUnavailable": "Recursive search is available in tree view only", - "collapseAllDisabled": "Not available in list view", - "dragDrop": { - "unableToResolveRoot": "Unable to determine destination path for move.", - "moveUnsupported": "Move is not supported for this item.", - "createFolderHint": "Release to create new folder", - "newFolderName": "New folder name", - "folderNameHint": "Press Enter to confirm, Escape to cancel", - "emptyFolderName": "Please enter a folder name", - "invalidFolderName": "Folder name contains invalid characters", - "noDragState": "No pending drag operation found" - }, - "empty": { - "noFolders": "No folders found", - "dragHint": "Drag items here to create folders" - }, - "folderUpdateCheck": { - "label": "Check for updates in this folder", - "loading": "Checking {type} updates for this folder...", - "success": "Found {count} update(s) for {type}s in this folder", - "none": "All {type}s in this folder are up to date", - "error": "Failed to check folder for {type} updates: {message}" - } + "badges": { + "update": "Update", + "updateAvailable": "Update available", + "skipRefresh": "Metadata refresh skipped" }, - "statistics": { - "title": "Statistics", - "tabs": { - "overview": "Overview", - "usage": "Usage Analysis", - "collection": "Collection", - "storage": "Storage", - "insights": "Insights" - }, - "metrics": { - "totalModels": "Total Models", - "totalStorage": "Total Storage", - "totalGenerations": "Total Generations", - "usageRate": "Usage Rate", - "loras": "LoRAs", - "checkpoints": "Checkpoints", - "embeddings": "Embeddings", - "uniqueTags": "Unique Tags", - "unusedModels": "Unused Models", - "avgUsesPerModel": "Avg. Uses/Model" - }, - "usage": { - "mostUsedLoras": "Most Used LoRAs", - "mostUsedCheckpoints": "Most Used Checkpoints", - "mostUsedEmbeddings": "Most Used Embeddings" - }, - "collection": { - "popularTags": "Popular Tags", - "modelTypes": "Model Types", - "collectionAnalysis": "Collection Analysis" - }, - "storage": { - "storageUsage": "Storage Usage", - "largestModels": "Largest Models", - "storageEfficiency": "Storage vs Usage Efficiency" - }, - "insights": { - "smartInsights": "Smart Insights", - "recommendations": "Recommendations", - "noInsights": "No insights available", - "unusedLoras": { - "high": { - "title": "High Number of Unused LoRAs", - "description": "{percent}% of your LoRAs ({count}/{total}) have never been used.", - "suggestion": "Consider organizing or archiving unused models to free up storage space." - } - }, - "unusedCheckpoints": { - "detected": { - "title": "Unused Checkpoints Detected", - "description": "{percent}% of your checkpoints ({count}/{total}) have never been used.", - "suggestion": "Review and consider removing checkpoints you no longer need." - } - }, - "unusedEmbeddings": { - "high": { - "title": "High Number of Unused Embeddings", - "description": "{percent}% of your embeddings ({count}/{total}) have never been used.", - "suggestion": "Consider organizing or archiving unused embeddings to optimize your collection." - } - }, - "collection": { - "large": { - "title": "Large Collection Detected", - "description": "Your model collection is using {size} of storage.", - "suggestion": "Consider using external storage or cloud solutions for better organization." - } - }, - "activity": { - "active": { - "title": "Active User", - "description": "You've completed {count} generations so far!", - "suggestion": "Keep exploring and creating amazing content with your models." - } - } - }, - "charts": { - "collectionOverview": "Collection Overview", - "baseModelDistribution": "Base Model Distribution", - "usageTrends": "Usage Trends (Last 30 Days)", - "usageDistribution": "Usage Distribution", - "date": "Date", - "usageCount": "Usage Count", - "fileSizeBytes": "File Size (bytes)", - "models": "Models", - "loraUsage": "LoRA Usage", - "checkpointUsage": "Checkpoint Usage", - "embeddingUsage": "Embedding Usage" - }, - "modelTypes": { - "lora": "LoRA", - "locon": "LyCORIS", - "dora": "DoRA", - "checkpoint": "Checkpoint", - "diffusion_model": "Diffusion Model", - "embedding": "Embeddings" - }, - "placeholders": { - "loading": "Loading...", - "noModels": "No models found", - "errorLoading": "Error loading data", - "noStorageData": "No storage data available", - "rootFolder": "Root", - "chartLibraryMissing": "Chart requires Chart.js library" - }, - "tooltips": { - "tagCount": "{tag}: {count} models", - "chartUsage": "{name}: {size}, {count} uses", - "chartPercentage": "{label}: {value} ({pct}%)" - } + "usage": { + "timesUsed": "Times used" }, - "modals": { - "exclude": { - "confirm": "Exclude" - }, - "download": { - "title": "Download Model from URL", - "titleWithType": "Download {type} from URL", - "civitaiUrl": "Civitai URL(s):", - "placeholder": "https://civitai.com/models/...", - "urlHint": "Enter one CivitAI, CivArchive, or Hugging Face URL per line. Supports multiple URLs for batch download.", - "selectHfFiles": "Select file(s) to download from this repository:", - "selectAll": "Select All", - "fetchingRepoFiles": "Fetching repository files...", - "locationPreview": "Download Location Preview", - "useDefaultPath": "Use Default Path", - "useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates", - "selectRootDirectory": "Select a root directory", - "selectModelRoot": "Select Model Root:", - "selectTypeRoot": "Select {type} Root:", - "targetFolderPath": "Target Folder Path:", - "browseFolders": "Browse Folders:", - "createNewFolder": "Create new folder", - "pathPlaceholder": "Type folder path or select from tree below...", - "root": "Root", - "download": "Download", - "fetchingVersions": "Fetching model versions...", - "versionPreview": "Version preview", - "earlyAccess": "Early Access", - "earlyAccessTooltip": "Early access required", - "inLibrary": "In Library", - "downloaded": "Downloaded", - "downloadedTooltip": "Previously downloaded, but it is not currently in your library.", - "alreadyInLibrary": "Already in Library", - "autoOrganizedPath": "[Auto-organized by path template]", - "fileSelection": { - "title": "Select File Format", - "files": "files", - "select": "Select File" - }, - "errors": { - "invalidUrl": "Invalid Civitai URL format", - "noVersions": "No versions available for this model", - "mixedSources": "Cannot mix CivitAI and Hugging Face URLs in the same batch.", - "noModelFiles": "No model files found in this repository." - }, - "status": { - "preparing": "Preparing download...", - "downloadedPreview": "Downloaded preview image", - "downloadingFile": "Downloading {type} file", - "finalizing": "Finalizing download..." - }, - "progress": { - "currentFile": "Current file:", - "downloading": "Downloading: {name}", - "transferred": "Transferred: {downloaded} / {total}", - "transferredSimple": "Transferred: {downloaded}", - "transferredUnknown": "Transferred: --", - "speed": "Speed: {speed}" - } - }, - "move": { - "title": "Move Models" - }, - "contentRating": { - "title": "Set Content Rating", - "current": "Current", - "multiple": "Multiple values", - "levels": { - "pg": "PG", - "pg13": "PG13", - "r": "R", - "x": "X", - "xxx": "XXX" - } - }, - "deleteModel": { - "title": "Delete Model", - "message": "Are you sure you want to delete this model and all associated files?" - }, - "excludeModel": { - "title": "Exclude Model", - "message": "Are you sure you want to exclude this model? Excluded models won't appear in searches or model lists." - }, - "deleteDuplicateRecipes": { - "title": "Delete Duplicate Recipes", - "message": "Are you sure you want to delete the selected duplicate recipes?", - "countMessage": "recipes will be permanently deleted." - }, - "deleteDuplicateModels": { - "title": "Delete Duplicate Models", - "message": "Are you sure you want to delete the selected duplicate models?", - "countMessage": "models will be permanently deleted." - }, - "clearCache": { - "title": "Clear Cache Files", - "message": "Are you sure you want to clear all cache files?", - "description": "This will remove all cached model data. The system will need to rebuild the cache on next startup, which may take some time depending on your model collection size.", - "action": "Clear Cache" - }, - "bulkDelete": { - "title": "Delete Multiple Models", - "message": "Are you sure you want to delete all selected models and their associated files?", - "countMessage": "models will be permanently deleted.", - "action": "Delete All" - }, - "bulkDeleteRecipes": { - "title": "Delete Multiple Recipes", - "message": "Are you sure you want to delete all selected recipes and their associated files?", - "countMessage": "recipes will be permanently deleted.", - "action": "Delete All" - }, - "checkUpdates": { - "title": "Check updates for all {typePlural}?", - "message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.", - "tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".", - "action": "Check All" - }, - "bulkAddTags": { - "title": "Add Tags to Multiple Models", - "description": "Add tags to", - "models": "models", - "tagsToAdd": "Tags to Add", - "placeholder": "Enter tag and press Enter...", - "appendTags": "Append Tags", - "replaceTags": "Replace Tags", - "saveChanges": "Save changes" - }, - "bulkBaseModel": { - "title": "Set Base Model for Multiple Models", - "description": "Set base model for", - "models": "models", - "selectBaseModel": "Select Base Model", - "save": "Update Base Model", - "cancel": "Cancel" - }, - "bulkDownloadMissingLoras": { - "title": "Download Missing LoRAs", - "message": "Found {uniqueCount} unique missing LoRAs (from {totalCount} total across selected recipes).", - "previewTitle": "LoRAs to download:", - "moreItems": "...and {count} more", - "note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.", - "downloadButton": "Download {count} LoRA(s)" - }, - "exampleAccess": { - "title": "Local Example Images", - "message": "No local example images found for this model. View options:", - "downloadOption": { - "title": "Download from Civitai", - "description": "Save remote examples locally for offline use and faster loading" - }, - "importOption": { - "title": "Import Your Own", - "description": "Add your own custom examples for this model" - }, - "footerNote": "Remote examples are still viewable in the model details even without local copies" - }, - "moveModel": { - "targetLocationPreview": "Target Location Preview:", - "selectModelRoot": "Select Model Root:", - "targetFolderPath": "Target Folder Path:", - "browseFolders": "Browse Folders:", - "createNewFolder": "Create new folder", - "pathPlaceholder": "Type folder path or select from tree below...", - "root": "Root" - }, - "relinkCivitai": { - "title": "Re-link to Civitai", - "warning": "Warning:", - "warningText": "This is a potentially destructive operation. Re-linking will:", - "warningList": { - "overrideMetadata": "Override existing metadata", - "modifyHash": "Potentially modify the model hash", - "unintendedConsequences": "May have other unintended consequences" - }, - "proceedText": "Only proceed if you're sure this is what you want.", - "urlLabel": "Civitai Model URL:", - "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 or https://civitai.red/models/649516/model-name?modelVersionId=726676", - "helpText": { - "title": "Paste any Civitai model URL from civitai.com or civitai.red. Supported formats:", - "format1": "https://civitai.com/models/649516", - "format2": "https://civitai.com/models/649516?modelVersionId=726676", - "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", - "note": "Note: If no modelVersionId is provided, the latest version will be used." - }, - "confirmAction": "Confirm Re-link" - }, - "model": { - "actions": { - "editModelName": "Edit model name", - "editFileName": "Edit file name", - "editBaseModel": "Edit base model", - "editVersionName": "Edit version name", - "viewOnCivitai": "View on Civitai", - "viewOnCivitaiText": "View on Civitai", - "viewOnHuggingFace": "View on Hugging Face", - "viewOnHuggingFaceText": "View on Hugging Face", - "viewCreatorProfile": "View Creator Profile", - "openFileLocation": "Open File Location", - "sendToWorkflow": "Send to ComfyUI", - "sendToWorkflowText": "Send to ComfyUI" - }, - "openFileLocation": { - "success": "File location opened successfully", - "failed": "Failed to open file location", - "copied": "Path copied to clipboard: {{path}}", - "clipboardFallback": "Path: {{path}}" - }, - "sendToWorkflow": { - "noFilePath": "Unable to send to ComfyUI: No file path available" - }, - "metadata": { - "version": "Version", - "fileName": "File Name", - "location": "Location", - "baseModel": "Base Model", - "size": "Size", - "unknown": "Unknown", - "usageTips": "Usage Tips", - "additionalNotes": "Additional Notes", - "notesHint": "Press Enter to save, Shift+Enter for new line", - "addNotesPlaceholder": "Add your notes here...", - "aboutThisVersion": "About this version", - "baseModelSearchPlaceholder": "Search base model…", - "baseModelSuggested": "Suggested", - "baseModelNoMatch": "No matching base models" - }, - "notes": { - "saved": "Notes saved successfully", - "saveFailed": "Failed to save notes", - "showMore": "Show more", - "showLess": "Show less" - }, - "usageTips": { - "addPresetParameter": "Add preset parameter...", - "strengthMin": "Strength Min", - "strengthMax": "Strength Max", - "strengthRange": "Strength Range", - "strength": "Strength", - "clipStrength": "Clip Strength", - "clipSkip": "Clip Skip", - "valuePlaceholder": "Value", - "add": "Add", - "invalidRange": "Invalid range format. Use x.x-y.y" - }, - "triggerWords": { - "label": "Trigger Words", - "noTriggerWordsNeeded": "No trigger word needed", - "edit": "Edit trigger words", - "cancel": "Cancel editing", - "save": "Save changes", - "addPlaceholder": "Type to add or click suggestions below", - "editWord": "Edit trigger word", - "editPlaceholder": "Edit trigger word", - "copyWord": "Copy trigger word", - "deleteWord": "Delete trigger word", - "suggestions": { - "noSuggestions": "No suggestions available", - "noTrainedWords": "No trained words or class tokens found in this model. You can manually enter trigger words.", - "classToken": "Class Token", - "classTokenDescription": "Add to your prompt for best results", - "wordSuggestions": "Word Suggestions", - "wordsFound": "{count} words found", - "loading": "Loading suggestions..." - } - }, - "description": { - "noDescription": "No model description available", - "failedToLoad": "Failed to load model description", - "editTitle": "Edit model description", - "validation": { - "cannotBeEmpty": "Description cannot be empty" - }, - "messages": { - "updated": "Model description updated", - "updateFailed": "Failed to update model description" - } - }, - "tabs": { - "examples": "Examples", - "description": "Model Description", - "recipes": "Recipes", - "versions": "Versions" - }, - "navigation": { - "label": "Model navigation", - "previousWithShortcut": "Previous model (←)", - "nextWithShortcut": "Next model (→)", - "noPrevious": "No previous model available", - "noNext": "No next model available" - }, - "license": { - "noImageSell": "No selling generated content", - "noRentCivit": "No Civitai generation", - "noRent": "No generation services", - "noSell": "No selling models", - "creditRequired": "Creator credit required", - "noDerivatives": "No sharing merges", - "noReLicense": "Same permissions required", - "restrictionsLabel": "License restrictions" - }, - "loading": { - "exampleImages": "Loading example images...", - "description": "Loading model description...", - "recipes": "Loading recipes...", - "examples": "Loading examples...", - "versions": "Loading versions..." - }, - "versions": { - "heading": "Model versions", - "copy": "Track and manage every version of this model in one place.", - "media": { - "placeholder": "No preview" - }, - "labels": { - "unnamed": "Untitled Version", - "noDetails": "No additional details", - "earlyAccess": "EA" - }, - "eaTime": { - "endingSoon": "ending soon", - "hours": "in {count}h", - "days": "in {count}d" - }, - "badges": { - "current": "Opened Version", - "currentTooltip": "This is the version you opened this modal from", - "inLibrary": "In Library", - "inLibraryTooltip": "This version exists in your local library", - "downloaded": "Downloaded", - "downloadedTooltip": "This version was downloaded before, but is not currently in your library", - "newer": "Newer Version", - "newerTooltip": "This version is newer than your latest local version", - "earlyAccess": "Early Access", - "earlyAccessTooltip": "This version currently requires Civitai early access", - "ignored": "Ignored", - "ignoredTooltip": "Update notifications are disabled for this version", - "onSiteOnly": "On-Site Only", - "onSiteOnlyTooltip": "This version is only available for on-site generation on Civitai" - }, - "actions": { - "download": "Download", - "downloadTooltip": "Download this version", - "downloadEarlyAccessTooltip": "Download this early access version from Civitai", - "downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai", - "delete": "Delete", - "deleteTooltip": "Delete this local version", - "ignore": "Ignore", - "unignore": "Unignore", - "ignoreTooltip": "Ignore update notifications for this version", - "unignoreTooltip": "Resume update notifications for this version", - "viewVersionOnCivitai": "View version on Civitai", - "earlyAccessTooltip": "Requires early access purchase", - "resumeModelUpdates": "Resume updates for this model", - "ignoreModelUpdates": "Ignore updates for this model", - "viewLocalVersions": "View all local versions", - "viewLocalTooltip": "Show all local versions of this model on the main page" - }, - "filters": { - "label": "Base filter", - "state": { - "showAll": "All versions", - "showSameBase": "Same base" - }, - "tooltip": { - "showAllVersions": "Switch to showing all versions", - "showSameBaseVersions": "Switch to showing only versions that match the current base model" - }, - "empty": "No versions match the current base model filter." - }, - "empty": "No version history available for this model yet.", - "error": "Failed to load versions.", - "missingModelId": "This model is missing a Civitai model id.", - "confirm": { - "delete": "Delete this version from your library?" - }, - "toast": { - "modelIgnored": "Updates ignored for this model", - "modelResumed": "Update tracking resumed", - "versionIgnored": "Updates ignored for this version", - "versionUnignored": "Version re-enabled", - "versionDeleted": "Version deleted" - } - } - }, - "metadataFetchSummary": { - "title": "Metadata Fetch Summary", - "statSuccess": "Success", - "statFailed": "Failed", - "statSkipped": "Skipped", - "statTotal": "Total Scanned", - "statDuration": "Duration", - "successMessage": "All {count} {type}s updated successfully!", - "failedItems": "Failed Items ({count})", - "close": "Close", - "copyReport": "Copy Report", - "downloadCsv": "Download CSV", - "columnModelName": "Model Name", - "columnError": "Error" - } + "footer": { + "versionCount": "{count} versions", + "viewAllVersions": "View all local versions" + } + }, + "globalContextMenu": { + "downloadExampleImages": { + "label": "Download example images", + "missingPath": "Set a download location before downloading example images.", + "unavailable": "Example image downloads aren't available yet. Try again after the page finishes loading." }, - "modelTags": { - "messages": { - "updated": "Tags updated successfully", - "updateFailed": "Failed to update tags" - }, - "validation": { - "maxLength": "Tag should not exceed 30 characters", - "maxCount": "Maximum 30 tags allowed", - "duplicate": "This tag already exists" - } + "checkModelUpdates": { + "label": "Check for updates", + "loading": "Checking for {type} updates...", + "success": "Found {count} update(s) for {type}s", + "none": "All {type}s are up to date", + "error": "Failed to check for {type} updates: {message}" }, - "initialization": { - "title": "Initializing", - "message": "Preparing your workspace...", - "status": "Initializing...", - "estimatingTime": "Estimating time...", - "loras": { - "title": "Initializing LoRA Manager", - "message": "Scanning and building LoRA cache. This may take a few minutes..." - }, - "checkpoints": { - "title": "Initializing Checkpoint Manager", - "message": "Scanning and building checkpoint cache. This may take a few minutes..." - }, - "embeddings": { - "title": "Initializing Embedding Manager", - "message": "Scanning and building embedding cache. This may take a few minutes..." - }, - "recipes": { - "title": "Initializing Recipe Manager", - "message": "Loading and processing recipes. This may take a few minutes..." - }, - "statistics": { - "title": "Initializing Statistics", - "message": "Processing model data for statistics. This may take a few minutes..." - }, - "tips": { - "title": "Tips & Tricks", - "civitai": { - "title": "Civitai Integration", - "description": "Connect your Civitai account: Visit Profile Avatar → Settings → API Keys → Add API Key, then paste it in Lora Manager settings.", - "alt": "Civitai API Setup" - }, - "download": { - "title": "Easy Download", - "description": "Use Civitai URLs to quickly download and install new models.", - "alt": "Civitai Download" - }, - "recipes": { - "title": "Save Recipes", - "description": "Create recipes to save your favorite model combinations for future use.", - "alt": "Recipes" - }, - "filter": { - "title": "Fast Filtering", - "description": "Filter models by tags or base model type using the filter button in the header.", - "alt": "Filter Models" - }, - "search": { - "title": "Quick Search", - "description": "Press Ctrl+F (Cmd+F on Mac) to quickly search within your current view.", - "alt": "Quick Search" - } + "cleanupExampleImages": { + "label": "Clean up example image folders", + "success": "Moved {count} folder(s) to the deleted folder", + "none": "No example image folders needed cleanup", + "partial": "Cleanup completed with {failures} folder(s) skipped", + "error": "Failed to clean example image folders: {message}" + }, + "fetchMissingLicenses": { + "label": "Refresh license metadata", + "loading": "Refreshing license metadata for {typePlural}...", + "success": "Updated license metadata for {count} {typePlural}", + "none": "All {typePlural} already have license metadata", + "error": "Failed to refresh license metadata for {typePlural}: {message}" + }, + "repairRecipes": { + "label": "Repair recipes data", + "loading": "Repairing recipe data...", + "success": "Successfully repaired {count} recipes.", + "cancelled": "Repair cancelled. {count} recipes were repaired.", + "error": "Recipe repair failed: {message}" + }, + "manageExcludedModels": { + "label": "Manage Excluded Models" + }, + "groupByModel": { + "label": "Group by Model" + } + }, + "header": { + "appTitle": "LoRA Manager", + "navigation": { + "loras": "LoRAs", + "recipes": "Recipes", + "checkpoints": "Checkpoints", + "embeddings": "Embeddings", + "statistics": "Stats" + }, + "search": { + "placeholder": "Search", + "options": "Search Options", + "searchIn": "Search In:", + "notAvailable": "Search not available on statistics page", + "filters": { + "filename": "Filename", + "modelname": "Model Name", + "tags": "Tags", + "creator": "Creator", + "title": "Recipe Title", + "loraName": "LoRA Filename", + "loraModel": "LoRA Model Name", + "prompt": "Prompt" + } + }, + "filter": { + "title": "Filter Models", + "presets": "Presets", + "savePreset": "Save current active filters as a new preset.", + "savePresetDisabledActive": "Cannot save: A preset is already active. Modify filters to save new preset.", + "savePresetDisabledNoFilters": "Select filters first to save as preset", + "savePresetPrompt": "Enter preset name:", + "presetClickTooltip": "Click to apply preset \"{name}\"", + "presetDeleteTooltip": "Delete preset", + "presetDeleteConfirm": "Delete preset \"{name}\"?", + "presetDeleteConfirmClick": "Click again to confirm", + "presetOverwriteConfirm": "Preset \"{name}\" already exists. Overwrite?", + "presetNamePlaceholder": "Preset name...", + "baseModel": "Base Model", + "baseModelSearchPlaceholder": "Search base models...", + "modelTags": "Tags (Top 20)", + "modelTypes": "Model Types", + "license": "License", + "noCreditRequired": "No Credit Required", + "allowSellingGeneratedContent": "Allow Selling", + "allowSellingGeneratedContentTooltip": "Allow selling generated images", + "noCreditRequiredTooltip": "Use the model without crediting the creator", + "noTags": "No tags", + "autoTags": "Auto Tags", + "noBaseModelMatches": "No base models match the current search.", + "clearAll": "Clear All Filters", + "any": "Any", + "all": "All", + "tagLogicAny": "Match any tag (OR)", + "tagLogicAll": "Match all tags (AND)" + }, + "theme": { + "toggle": "Toggle theme", + "switchToLight": "Switch to light theme", + "switchToDark": "Switch to dark theme", + "switchToAuto": "Switch to auto theme", + "presets": "Theme Presets", + "default": "Default", + "nord": "Nord", + "midnight": "Midnight", + "monokai": "Monokai", + "dracula": "Dracula", + "solarized": "Solarized", + "mode": "Mode", + "light": "Light", + "dark": "Dark", + "auto": "Auto" + }, + "actions": { + "checkUpdates": "Check Updates", + "notifications": "Notifications", + "support": "Support" + } + }, + "settings": { + "civitaiApiKey": "Civitai API Key", + "civitaiApiKeyPlaceholder": "Enter your Civitai API key", + "civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai", + "civitaiApiKeyConfigured": "Configured", + "civitaiApiKeyNotConfigured": "Not configured", + "civitaiApiKeySet": "Set up", + "civitaiHost": { + "label": "Civitai host", + "help": "Choose which Civitai site opens when using View on Civitai links.", + "options": { + "com": "civitai.com (SFW)", + "red": "civitai.red (unrestricted)" + } + }, + "downloadBackend": { + "label": "Download backend", + "help": "Choose how model files are downloaded. Python uses the built-in downloader. aria2 uses the recommended external downloader process.", + "options": { + "python": "Python (built-in)", + "aria2": "aria2 (recommended)" + } + }, + "aria2cPath": { + "label": "aria2c path", + "help": "Optional path to the aria2c executable. Leave empty to use aria2c from your system PATH.", + "placeholder": "Leave empty to use aria2c from PATH" + }, + "aria2HelpLink": "Learn how to set up the aria2 download backend", + "civitaiHostBanner": { + "title": "Civitai host preference available", + "content": "Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.", + "openSettings": "Open Settings" + }, + "openSettingsFileLocation": { + "label": "Open settings folder", + "tooltip": "Open folder containing settings.json", + "success": "Opened settings.json folder", + "failed": "Failed to open settings.json folder", + "copied": "Settings path copied to clipboard: {{path}}", + "clipboardFallback": "Settings path: {{path}}" + }, + "sections": { + "contentFiltering": "Content Filtering", + "downloads": "Downloads", + "videoSettings": "Video Settings", + "layoutSettings": "Layout Settings", + "licenseIcons": "License Icons", + "misc": "Miscellaneous", + "backup": "Backups", + "folderSettings": "Default Roots", + "recipeSettings": "Recipes", + "extraFolderPaths": "Extra Folder Paths", + "downloadPathTemplates": "Download Path Templates", + "priorityTags": "Priority Tags", + "versionScope": "Version Scope", + "exampleImages": "Example Images", + "autoOrganize": "Auto-organize", + "metadata": "Metadata", + "proxySettings": "Proxy Settings" + }, + "nav": { + "general": "General", + "interface": "Interface", + "library": "Library" + }, + "search": { + "placeholder": "Search settings...", + "clear": "Clear search", + "noResults": "No settings found matching \"{query}\"" + }, + "storage": { + "locationLabel": "Portable mode", + "locationHelp": "Enable to keep settings.json inside the repository; disable to store it in your user config directory." + }, + "contentFiltering": { + "blurNsfwContent": "Blur NSFW Content", + "blurNsfwContentHelp": "Blur mature (NSFW) content preview images", + "showOnlySfw": "Show Only SFW Results", + "showOnlySfwHelp": "Filter out all NSFW content when browsing and searching", + "matureBlurThreshold": "Mature Blur Threshold", + "matureBlurThresholdHelp": "Set which rating level starts blur filtering when NSFW blur is enabled.", + "matureBlurThresholdOptions": { + "pg13": "PG13 and above", + "r": "R and above (default)", + "x": "X and above", + "xxx": "XXX only" + } + }, + "videoSettings": { + "autoplayOnHover": "Autoplay Videos on Hover", + "autoplayOnHoverHelp": "Only play video previews when hovering over them" + }, + "autoOrganizeExclusions": { + "label": "Auto-organize exclusions", + "placeholder": "Example: curated/*, */backups/*; *_temp.safetensors", + "help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.", + "validation": { + "noPatterns": "Enter at least one pattern separated by commas or semicolons.", + "saveFailed": "Unable to save exclusions: {message}" + } + }, + "metadataRefreshSkipPaths": { + "label": "Metadata refresh skip paths", + "placeholder": "Example: temp, archived/old, test_models", + "help": "Skip models in these directory paths during bulk metadata refresh (\"Fetch All Metadata\"). Enter folder paths relative to your model root directory, separated by commas.", + "validation": { + "noPaths": "Enter at least one path separated by commas.", + "saveFailed": "Unable to save skip paths: {message}" + } + }, + "backup": { + "autoEnabled": "Automatic backups", + "autoEnabledHelp": "Create a local snapshot once per day and keep the latest snapshots according to the retention policy.", + "retention": "Retention count", + "retentionHelp": "How many automatic snapshots to keep before older ones are pruned.", + "management": "Backup management", + "managementHelp": "Export your current user state or restore it from a backup archive.", + "scopeHelp": "Backs up your settings, download history, and model update state. It does not include model files or rebuildable caches.", + "locationSummary": "Current backup location", + "openFolderButton": "Open backup folder", + "openFolderSuccess": "Opened backup folder", + "openFolderFailed": "Failed to open backup folder", + "locationCopied": "Backup path copied to clipboard: {{path}}", + "locationClipboardFallback": "Backup path: {{path}}", + "exportButton": "Export backup", + "exportSuccess": "Backup exported successfully.", + "exportFailed": "Failed to export backup: {message}", + "importButton": "Import backup", + "importConfirm": "Import this backup and overwrite local user state?", + "importSuccess": "Backup imported successfully.", + "importFailed": "Failed to import backup: {message}", + "latestSnapshot": "Latest snapshot", + "latestAutoSnapshot": "Latest automatic snapshot", + "snapshotCount": "Saved snapshots", + "noneAvailable": "No snapshots yet" + }, + "downloadSkipBaseModels": { + "label": "Skip downloads for base models", + "help": "When enabled, versions using the selected base models will be skipped.", + "searchPlaceholder": "Filter base models...", + "empty": "No base models match the current search.", + "summary": { + "none": "None selected", + "count": "{count} selected" + }, + "actions": { + "edit": "Edit", + "collapse": "Collapse", + "clear": "Clear" + }, + "validation": { + "saveFailed": "Unable to save excluded base models: {message}" + } + }, + "skipPreviouslyDownloadedModelVersions": { + "label": "Skip previously downloaded model versions", + "help": "When enabled, versions downloaded before will be skipped." + }, + "layoutSettings": { + "groupByModel": "Group by Model", + "groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.", + "displayDensity": "Display Density", + "displayDensityOptions": { + "default": "Default", + "medium": "Medium", + "compact": "Compact" + }, + "displayDensityHelp": "Choose how many cards to display per row:", + "displayDensityDetails": { + "default": "5 (1080p), 6 (2K), 8 (4K)", + "medium": "6 (1080p), 7 (2K), 9 (4K)", + "compact": "7 (1080p), 8 (2K), 10 (4K)" + }, + "displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.", + "showFolderSidebar": "Show Folder Sidebar", + "showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.", + "cardInfoDisplay": "Card Info Display", + "cardInfoDisplayOptions": { + "always": "Always Visible", + "hover": "Reveal on Hover" + }, + "cardInfoDisplayHelp": "Choose when to display model information and action buttons", + "showVersionOnCard": "Show Version on Card", + "showVersionOnCardHelp": "Show or hide the version name on model cards", + "modelCardFooterAction": "Model Card Button Action", + "modelCardFooterActionOptions": { + "exampleImages": "Open Example Images", + "replacePreview": "Replace Preview" + }, + "modelCardFooterActionHelp": "Choose what the bottom-right card button does", + "modelNameDisplay": "Model Name Display", + "modelNameDisplayOptions": { + "modelName": "Model Name", + "fileName": "File Name" + }, + "modelNameDisplayHelp": "Choose what to display in the model card footer", + "cardBlurAmount": "Card Overlay Blur", + "cardBlurAmountHelp": "Adjust the blur intensity of the header and footer overlays on model and recipe cards (0 = no blur, 20 = maximum blur)." + }, + "folderSettings": { + "activeLibrary": "Active Library", + "activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.", + "loadingLibraries": "Loading libraries...", + "noLibraries": "No libraries configured", + "defaultLoraRoot": "LoRA Root", + "defaultLoraRootHelp": "Set default LoRA root directory for downloads, imports and moves", + "defaultCheckpointRoot": "Checkpoint Root", + "defaultCheckpointRootHelp": "Set default checkpoint root directory for downloads, imports and moves", + "defaultUnetRoot": "Diffusion Model Root", + "defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves", + "defaultEmbeddingRoot": "Embedding Root", + "defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves", + "recipesPath": "Recipes Storage Path", + "recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.", + "recipesPathPlaceholder": "/path/to/recipes", + "recipesPathMigrating": "Migrating recipes storage...", + "noDefault": "No Default" + }, + "extraFolderPaths": { + "title": "Extra Folder Paths", + "description": "Additional model root paths exclusive to LoRA Manager. Load models from locations outside ComfyUI's standard folders—ideal for large libraries that would otherwise slow down ComfyUI.", + "restartRequired": "Requires restart to take effect", + "modelTypes": { + "lora": "LoRA Paths", + "checkpoint": "Checkpoint Paths", + "unet": "Diffusion Model Paths", + "embedding": "Embedding Paths" + }, + "pathPlaceholder": "/path/to/extra/models", + "saveSuccess": "Extra folder paths updated. Restart required to apply changes.", + "saveError": "Failed to update extra folder paths: {message}", + "validation": { + "duplicatePath": "This path is already configured" + } + }, + "priorityTags": { + "title": "Priority Tags", + "description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))", + "placeholder": "character, concept, style(toon|toon_style)", + "helpLinkLabel": "Open priority tags help", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "Checkpoint", + "embedding": "Embedding" + }, + "saveSuccess": "Priority tags updated.", + "saveError": "Failed to update priority tags.", + "loadingSuggestions": "Loading suggestions...", + "validation": { + "missingClosingParen": "Entry {index} is missing a closing parenthesis.", + "missingCanonical": "Entry {index} must include a canonical tag name.", + "duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.", + "unknown": "Invalid priority tag configuration." + } + }, + "downloadPathTemplates": { + "title": "Download Path Templates", + "help": "Configure folder structures for different model types when downloading from Civitai.", + "availablePlaceholders": "Available placeholders:", + "templateOptions": { + "flatStructure": "Flat Structure", + "byBaseModel": "By Base Model", + "byAuthor": "By Author", + "byFirstTag": "By First Tag", + "baseModelFirstTag": "Base Model + First Tag", + "baseModelAuthor": "Base Model + Author", + "authorFirstTag": "Author + First Tag", + "baseModelAuthorFirstTag": "Base Model + Author + First Tag", + "customTemplate": "Custom Template" + }, + "customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "Checkpoint", + "embedding": "Embedding" + }, + "baseModelPathMappings": "Base Model Path Mappings", + "baseModelPathMappingsHelp": "Customize folder names for specific base models (e.g., \"Flux.1 D\" → \"flux\")", + "addMapping": "Add Mapping", + "selectBaseModel": "Select Base Model", + "customPathPlaceholder": "Custom path (e.g., flux)", + "removeMapping": "Remove mapping", + "validation": { + "validFlat": "Valid (flat structure)", + "invalidChars": "Invalid characters detected", + "doubleSlashes": "Double slashes not allowed", + "leadingTrailingSlash": "Cannot start or end with slash", + "invalidPlaceholder": "Invalid placeholder: {placeholder}", + "validTemplate": "Valid template" + } + }, + "exampleImages": { + "downloadLocation": "Download Location", + "downloadLocationPlaceholder": "Enter folder path for example images", + "downloadLocationHelp": "Enter the folder path where example images from Civitai will be saved", + "autoDownload": "Auto Download Example Images", + "autoDownloadHelp": "Automatically download example images for models that don't have them (requires download location to be set)", + "openMode": "Open Example Images Action", + "openModeHelp": "Choose whether the action opens on the server, copies a mapped local path, or launches a custom URI.", + "openModeOptions": { + "system": "Open on server", + "clipboard": "Copy local path", + "uriTemplate": "Open custom URI" + }, + "localRoot": "Local Example Images Root", + "localRootHelp": "Optional local or mounted root that mirrors the server example images directory. If blank, the server path is reused.", + "localRootPlaceholder": "Example: /Volumes/ComfyUI/example_images", + "uriTemplate": "Open URI Template", + "uriTemplateHelp": "Use a custom deep link such as a file URI or a Shortcuts link.", + "uriTemplatePlaceholder": "Example: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}", + "uriTemplatePlaceholders": "Available placeholders: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}", + "openModeWikiLink": "Learn more about remote open modes", + "optimizeImages": "Optimize Downloaded Images", + "optimizeImagesHelp": "Optimize example images to reduce file size and improve loading speed (metadata will be preserved)", + "download": "Download", + "restartRequired": "Requires restart" + }, + "versionGrouping": { + "label": "Version Grouping", + "help": "Decide how versions are grouped for display: by base model or all together. Also controls update badge logic and the VLM version list filtering.", + "options": { + "sameBase": "Group by base model (same_base)", + "any": "Show all versions (any)" + } + }, + "hideEarlyAccessUpdates": { + "label": "Hide Early Access Updates", + "help": "When enabled, models with only early access updates will not show 'Update available' badge" + }, + "licenseIcons": { + "useNewStyle": "Use updated license icons", + "useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design." + }, + "misc": { + "includeTriggerWords": "Include Trigger Words in LoRA Syntax", + "includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard", + "loraSyntaxFormat": "LoRA Syntax Format", + "loraSyntaxFormatHelp": "LoRA syntax format. Full includes subfolder path () for lossless model resolution. Legacy uses filename only () — A1111 convention, may be ambiguous with duplicate filenames across folders.", + "loraSyntaxFormatOptions": { + "full": "Full path (subfolder/name)", + "legacy": "Legacy A1111 (name only)" + } + }, + "metadataArchive": { + "enableArchiveDb": "Enable Metadata Archive Database", + "enableArchiveDbHelp": "Use a local database to access metadata for models that have been deleted from Civitai.", + "status": "Status", + "statusAvailable": "Available", + "statusUnavailable": "Not Available", + "enabled": "Enabled", + "management": "Database Management", + "managementHelp": "Download or remove the metadata archive database", + "downloadButton": "Download Database", + "downloadingButton": "Downloading...", + "downloadedButton": "Downloaded", + "removeButton": "Remove Database", + "removingButton": "Removing...", + "downloadSuccess": "Metadata archive database downloaded successfully", + "downloadError": "Failed to download metadata archive database", + "removeSuccess": "Metadata archive database removed successfully", + "removeError": "Failed to remove metadata archive database", + "removeConfirm": "Are you sure you want to remove the metadata archive database? This will delete the local database file and you'll need to download it again to use this feature.", + "preparing": "Preparing download...", + "connecting": "Connecting to download server...", + "completed": "Completed", + "downloadComplete": "Download completed successfully" + }, + "proxySettings": { + "enableProxy": "Enable App-level Proxy", + "enableProxyHelp": "Enable custom proxy settings for this application, overriding system proxy settings", + "proxyType": "Proxy Type", + "proxyTypeHelp": "Select the type of proxy server (HTTP, HTTPS, SOCKS4, SOCKS5)", + "proxyHost": "Proxy Host", + "proxyHostPlaceholder": "proxy.example.com", + "proxyHostHelp": "The hostname or IP address of your proxy server", + "proxyPort": "Proxy Port", + "proxyPortPlaceholder": "8080", + "proxyPortHelp": "The port number of your proxy server", + "proxyUsername": "Username (Optional)", + "proxyUsernamePlaceholder": "username", + "proxyUsernameHelp": "Username for proxy authentication (if required)", + "proxyPassword": "Password (Optional)", + "proxyPasswordPlaceholder": "password", + "proxyPasswordHelp": "Password for proxy authentication (if required)" + }, + "aiProvider": { + "title": "AI Provider", + "provider": "Provider", + "providerHelp": "Choose your LLM provider. Preset providers set the API base URL automatically. Custom lets you specify any OpenAI-compatible endpoint.", + "providerOptions": { + "openai": "OpenAI", + "ollama": "Ollama (local)", + "deepseek": "DeepSeek", + "groq": "Groq", + "openrouter": "OpenRouter", + "opencode-go": "OpenCode Go", + "custom": "Custom (OpenAI-compatible)" + }, + "apiBase": "API Base URL", + "apiBaseHelp": "The base URL for the LLM API. Select a preset or enter a custom URL. The dropdown shows presets for all supported providers.", + "apiBasePlaceholder": "https://api.openai.com/v1", + "apiKey": "API Key", + "apiKeyHelp": "Your LLM provider API key. Stored locally, never sent to any server except your chosen LLM provider.", + "apiKeyPlaceholder": "sk-...", + "apiKeyNotSet": "Not set", + "apiKeyConfigured": "Configured", + "apiKeySet": "Set up", + "model": "Model", + "modelHelp": "The model to use. Select from the dropdown (fetched from your provider) or type a custom model name.", + "modelPlaceholder": "Select a model..." + } + }, + "loras": { + "controls": { + "sort": { + "title": "Sort models by...", + "name": "Name", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "Date Added", + "dateDesc": "Newest", + "dateAsc": "Oldest", + "size": "File Size", + "sizeDesc": "Largest", + "sizeAsc": "Smallest", + "usage": "Use Count", + "usageDesc": "Most", + "usageAsc": "Least", + "versionsCount": "Local Versions", + "versionsCountDesc": "Most versions first", + "versionsCountAsc": "Fewest versions first", + "versionIdDesc": "Newest version first" + }, + "refresh": { + "title": "Refresh model list", + "full": "Rebuild Cache", + "fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits." + }, + "fetch": { + "title": "Fetch metadata from Civitai", + "action": "Fetch" + }, + "download": { + "title": "Download from URL", + "action": "Download" + }, + "bulk": { + "title": "Bulk Operations", + "action": "Bulk" + }, + "duplicates": { + "title": "Find Duplicates", + "action": "Duplicates" + }, + "favorites": { + "title": "Show Favorites Only", + "action": "Favorites" + }, + "updates": { + "title": "Show models with updates available", + "action": "Updates", + "menuLabel": "Show update options", + "check": "Check updates", + "checkTooltip": "Checking updates may take a while." + } + }, + "bulkOperations": { + "selected": "{count} selected", + "selectedSuffix": "selected", + "viewSelected": "View Selected", + "addTags": "Add Tags to Selected", + "setBaseModel": "Set Base Model for Selected", + "setContentRating": "Set Content Rating for Selected", + "copyAll": "Copy Selected Syntax", + "refreshAll": "Refresh Selected Metadata", + "repairMetadata": "Repair Metadata for Selected", + "reimportMetadata": "Re-import from Source", + "checkUpdates": "Check Updates for Selected", + "moveAll": "Move Selected to Folder", + "autoOrganize": "Auto-Organize Selected", + "skipMetadataRefresh": "Skip Metadata Refresh for Selected", + "resumeMetadataRefresh": "Resume Metadata Refresh for Selected", + "setFavorite": "Set as Favorite", + "setFavoriteCount": "Set as Favorite ({favorited}/{total})", + "unfavorite": "Remove from Favorites", + "deleteAll": "Delete Selected", + "downloadMissingLoras": "Download Missing LoRAs", + "downloadExamples": "Download Example Images", + "clear": "Clear Selection", + "skipMetadataRefreshCount": "Skip ({count} models)", + "resumeMetadataRefreshCount": "Resume ({count} models)", + "sendToWorkflow": "Send to Workflow", + "sections": { + "workflow": "Workflow", + "metadata": "Metadata", + "attributes": "Attributes", + "organize": "Organize", + "download": "Download" + }, + "autoOrganizeProgress": { + "initializing": "Initializing auto-organize...", + "starting": "Starting auto-organize for {type}...", + "processing": "Processing ({processed}/{total}) - {success} moved, {skipped} skipped, {failures} failed", + "cleaning": "Cleaning up empty directories...", + "completed": "Completed: {success} moved, {skipped} skipped, {failures} failed", + "complete": "Auto-organize complete", + "error": "Error: {error}" + }, + "enrichHfAgent": "Enrich HF Metadata (AI)" + }, + "contextMenu": { + "refreshMetadata": "Refresh Civitai Data", + "checkUpdates": "Check Updates", + "relinkCivitai": "Re-link to Civitai", + "copySyntax": "Copy LoRA Syntax", + "copyFilename": "Copy Model Filename", + "copyRecipeSyntax": "Copy Recipe Syntax", + "sendToWorkflowAppend": "Send to Workflow (Append)", + "sendToWorkflowReplace": "Send to Workflow (Replace)", + "openExamples": "Open Examples Folder", + "downloadExamples": "Download Example Images", + "replacePreview": "Replace Preview", + "setContentRating": "Set Content Rating", + "moveToFolder": "Move to Folder", + "repairMetadata": "Repair metadata", + "reimportMetadata": "Re-import from Source", + "excludeModel": "Exclude Model", + "restoreModel": "Restore Model", + "deleteModel": "Delete Model", + "shareRecipe": "Share Recipe", + "viewAllLoras": "View All LoRAs", + "downloadMissingLoras": "Download Missing LoRAs", + "deleteRecipe": "Delete Recipe", + "enrichHfAgent": "Enrich HF Metadata (AI)" + } + }, + "recipes": { + "title": "LoRA Recipes", + "actions": { + "sendCheckpoint": "Send to ComfyUI" + }, + "controls": { + "import": { + "action": "Import", + "title": "Import a recipe from image or URL", + "urlLocalPath": "URL / Local Path", + "uploadImage": "Upload Image", + "urlSectionDescription": "Input a Civitai image URL from civitai.com or civitai.red, or a local file path, to import as a recipe.", + "imageUrlOrPath": "Image URL or File Path:", + "urlPlaceholder": "https://civitai.com/images/... or https://civitai.red/images/... or C:/path/to/image.png", + "fetchImage": "Fetch Image", + "uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.", + "selectImage": "Select Image", + "recipeName": "Recipe Name", + "recipeNamePlaceholder": "Enter recipe name", + "tagsOptional": "Tags (optional)", + "addTagPlaceholder": "Add a tag", + "addTag": "Add", + "noTagsAdded": "No tags added", + "lorasInRecipe": "LoRAs in this Recipe", + "downloadLocationPreview": "Download Location Preview:", + "useDefaultPath": "Use Default Path", + "useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates", + "selectLoraRoot": "Select a LoRA root directory", + "targetFolderPath": "Target Folder Path:", + "folderPathPlaceholder": "Type folder path or select from tree below...", + "createNewFolder": "Create new folder", + "root": "Root", + "browseFolders": "Browse Folders:", + "downloadAndSaveRecipe": "Download & Save Recipe", + "importRecipeOnly": "Import Recipe Only", + "importAndDownload": "Import & Download", + "downloadMissingLoras": "Download Missing LoRAs", + "saveRecipe": "Save Recipe", + "loraCountInfo": "({existing}/{total} in library)", + "processingInput": "Processing input...", + "analyzingMetadata": "Analyzing image metadata...", + "downloadingLoras": "Downloading LoRAs...", + "savingRecipe": "Saving recipe...", + "startingDownload": "Starting download for LoRA {current}/{total}", + "deletedFromCivitai": "Deleted from Civitai", + "inLibrary": "In Library", + "notInLibrary": "Not in Library", + "earlyAccessRequired": "This LoRA requires early access payment to download.", + "earlyAccessEnds": "Early access ends on {date}.", + "earlyAccess": "Early Access", + "verifyEarlyAccess": "Verify that you have purchased early access before downloading.", + "duplicateRecipesFound": "{count} identical recipe(s) found in your library", + "duplicateRecipesDescription": "These recipes contain the same LoRAs with identical weights.", + "showDuplicates": "Show duplicates", + "hideDuplicates": "Hide duplicates", + "loraCount": "{count} LoRAs", + "recipePreviewAlt": "Recipe preview", + "loraPreviewAlt": "LoRA preview", + "errors": { + "selectImageFile": "Please select an image file", + "enterUrlOrPath": "Please enter a URL or file path", + "selectLoraRoot": "Please select a LoRA root directory" } + }, + "sort": { + "title": "Sort recipes by...", + "name": "Name", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "Date", + "dateDesc": "Newest", + "dateAsc": "Oldest", + "lorasCount": "LoRA Count", + "lorasCountDesc": "Most", + "lorasCountAsc": "Least" + }, + "refresh": { + "title": "Refresh recipe list", + "full": "Rebuild Cache", + "fullTooltip": "Rebuild cache - full rescan of all recipe files" + }, + "filteredByLora": "Filtered by LoRA", + "favorites": { + "title": "Show Favorites Only", + "action": "Favorites" + } }, "duplicates": { - "found": "Found {count} duplicate groups", - "showNotification": "Show Duplicates Notification", - "deleteSelected": "Delete Selected", - "exitMode": "Exit Mode", - "help": { - "identicalHashes": "Identical hashes mean identical model files, even if they have different names or previews.", - "keepOne": "Keep only one version (preferably with better metadata/previews) and safely delete the others." - } + "found": "Found {count} duplicate groups", + "keepLatest": "Keep Latest Versions", + "deleteSelected": "Delete Selected" }, - "uiHelpers": { - "clipboard": { - "copied": "Copied to clipboard", - "copyFailed": "Copy failed" - }, - "lora": { - "syntaxCopied": "LoRA syntax copied to clipboard", - "syntaxCopiedNoTriggerWords": "LoRA syntax copied to clipboard (no trigger words found)", - "syntaxCopiedWithTriggerWords": "LoRA syntax with trigger words copied to clipboard", - "syntaxCopiedWithTriggerWordGroups": "LoRA syntax with trigger word groups copied to clipboard" - }, - "workflow": { - "noSupportedNodes": "No supported target nodes found in workflow", - "communicationFailed": "Failed to communicate with ComfyUI", - "loraAdded": "LoRA appended to workflow", - "loraReplaced": "LoRA replaced in workflow", - "loraFailedToSend": "Failed to send LoRA to workflow", - "recipeAdded": "Recipe appended to workflow", - "recipeReplaced": "Recipe replaced in workflow", - "recipeFailedToSend": "Failed to send recipe to workflow", - "noMatchingNodes": "No compatible nodes available in the current workflow", - "noTargetNodeSelected": "No target node selected", - "modelUpdated": "Model updated in workflow", - "modelFailed": "Failed to update model node", - "embeddingAdded": "Embedding added to workflow", - "embeddingFailed": "Failed to add embedding", - "promptSent": "Prompt sent to workflow", - "promptFailed": "Failed to send prompt" - }, - "nodeSelector": { - "recipe": "Recipe", - "lora": "LoRA", - "embedding": "Embedding", - "prompt": "Prompt", - "replace": "Replace", - "append": "Append", - "selectTargetNode": "Select target node", - "sendToAll": "Send to All" - }, - "exampleImages": { - "opened": "Example images folder opened", - "openingFolder": "Opening example images folder", - "failedToOpen": "Failed to open example images folder", - "copiedPath": "Path copied to clipboard: {{path}}", - "clipboardFallback": "Path: {{path}}", - "copiedUri": "Link copied to clipboard: {{uri}}", - "uriClipboardFallback": "Link: {{uri}}", - "setupRequired": "Example Images Storage", - "setupDescription": "To add custom example images, you need to set a download location first.", - "setupUsage": "This path is used for both downloaded and custom example images.", - "openSettings": "Open Settings" - } + "contextMenu": { + "copyRecipe": { + "missingId": "Cannot copy recipe: Missing recipe ID", + "failed": "Failed to copy recipe syntax" + }, + "sendRecipe": { + "missingId": "Cannot send recipe: Missing recipe ID", + "failed": "Failed to send recipe to workflow" + }, + "viewLoras": { + "missingId": "Cannot view LoRAs: Missing recipe ID", + "noLorasFound": "No LoRAs found in this recipe", + "loadError": "Error loading recipe LoRAs: {message}" + }, + "downloadMissing": { + "missingId": "Cannot download LoRAs: Missing recipe ID", + "noMissingLoras": "No missing LoRAs to download", + "getInfoFailed": "Failed to get information for missing LoRAs", + "prepareError": "Error preparing LoRAs for download: {message}" + }, + "repair": { + "starting": "Repairing recipe metadata...", + "success": "Recipe metadata repaired successfully", + "skipped": "Recipe already at latest version, no repair needed", + "failed": "Failed to repair recipe: {message}", + "missingId": "Cannot repair recipe: Missing recipe ID" + }, + "reimport": { + "starting": "Re-importing recipe from source...", + "success": "Recipe re-imported successfully", + "noSourceUrl": "Recipe has no source URL, cannot re-import", + "failed": "Failed to re-import recipe: {message}", + "missingId": "Cannot re-import recipe: Missing recipe ID" + } }, - "help": { - "title": "Help & Tutorials", - "tabs": { - "gettingStarted": "Getting Started", - "updateVlogs": "Update Vlogs", - "documentation": "Documentation" - }, - "gettingStarted": { - "title": "Getting Started with LoRA Manager" - }, - "updateVlogs": { - "title": "Latest Updates", - "watchOnYouTube": "Watch on YouTube", - "playlistTitle": "LoRA Manager Updates Playlist", - "playlistDescription": "Watch all update videos showcasing the latest features and improvements." - }, - "documentation": { - "title": "Documentation", - "general": "General", - "troubleshooting": "Troubleshooting", - "modelManagement": "Model Management", - "recipes": "Recipes", - "settings": "Settings & Configuration", - "extensions": "Extensions", - "newBadge": "NEW" - } + "batchImport": { + "title": "Batch Import Recipes", + "action": "Batch Import", + "urlList": "URL List", + "directory": "Directory", + "urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.", + "directoryDescription": "Enter a directory path to import all images from that folder.", + "urlsLabel": "Image URLs or Local Paths", + "urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...", + "urlsHint": "Enter one URL or path per line", + "directoryPath": "Directory Path", + "directoryPlaceholder": "/path/to/images/folder", + "browse": "Browse", + "recursive": "Include subdirectories", + "tagsOptional": "Tags (optional, applied to all recipes)", + "tagsPlaceholder": "Enter tags separated by commas", + "tagsHint": "Tags will be added to all imported recipes", + "skipNoMetadata": "Skip images without metadata", + "skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.", + "start": "Start Import", + "startImport": "Start Import", + "importing": "Importing...", + "progress": "Progress", + "total": "Total", + "success": "Success", + "failed": "Failed", + "skipped": "Skipped", + "current": "Current", + "currentItem": "Current", + "preparing": "Preparing...", + "cancel": "Cancel", + "cancelImport": "Cancel", + "cancelled": "Import cancelled", + "completed": "Import completed", + "completedWithErrors": "Completed with errors", + "completedSuccess": "Successfully imported {count} recipe(s)", + "successCount": "Successful", + "failedCount": "Failed", + "skippedCount": "Skipped", + "totalProcessed": "Total processed", + "viewDetails": "View Details", + "newImport": "New Import", + "manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.", + "batchImportDirectorySelected": "Directory selected: {path}", + "batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.", + "backToParent": "Back to parent directory", + "folders": "Folders", + "folderCount": "{count} folders", + "imageFiles": "Image Files", + "images": "images", + "imageCount": "{count} images", + "selectFolder": "Select This Folder", + "errors": { + "enterUrls": "Please enter at least one URL or path", + "enterDirectory": "Please enter a directory path", + "startFailed": "Failed to start import: {message}" + } + } + }, + "checkpoints": { + "title": "Checkpoint Models", + "modelTypes": { + "checkpoint": "Checkpoint", + "diffusion_model": "Diffusion Model" }, - "update": { - "title": "Check for Updates", - "notificationsTitle": "Notifications", - "tabs": { - "updates": "Updates", - "messages": "Messages" - }, - "updateAvailable": "Update Available", - "noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.", - "currentVersion": "Current Version", - "newVersion": "New Version", - "commit": "Commit", - "viewOnGitHub": "View on GitHub", - "updateNow": "Update Now", - "preparingUpdate": "Preparing update...", - "changelog": "Changelog", - "checkingUpdates": "Checking for updates...", - "checkingMessage": "Please wait while we check for the latest version.", - "showNotifications": "Show update notifications", - "latestBadge": "Latest", - "updateProgress": { - "preparing": "Preparing update...", - "installing": "Installing update...", - "completed": "Update completed successfully!", - "failed": "Update failed: {error}" - }, - "status": { - "updating": "Updating...", - "updated": "Updated!", - "updateFailed": "Update Failed" - }, - "completion": { - "successMessage": "Successfully updated to {version}!", - "restartMessage": "Please restart ComfyUI or LoRA Manager to apply update.", - "reloadMessage": "Make sure to reload your browser for both LoRA Manager and ComfyUI." - }, - "nightly": { - "warning": "Warning: Nightly builds may contain experimental features and could be unstable.", - "enable": "Enable Nightly Updates" - }, - "banners": { - "recent": "Recent messages", - "empty": "No recent banners yet.", - "shown": "Shown {time}", - "dismissed": "Dismissed {time}", - "active": "Active" - } + "contextMenu": { + "moveToOtherTypeFolder": "Move to {otherType} Folder", + "sendToWorkflow": "Send to Workflow" + } + }, + "embeddings": { + "title": "Embedding Models" + }, + "sidebar": { + "modelRoot": "Root", + "collapseAll": "Collapse All Folders", + "hideOnThisPage": "Hide sidebar on this page", + "showSidebar": "Show sidebar", + "sidebarHiddenNotification": "Folder sidebar hidden on {page} page", + "switchToListView": "Switch to List View", + "switchToTreeView": "Switch to Tree View", + "recursiveOn": "Include subfolders", + "recursiveOff": "Current folder only", + "recursiveUnavailable": "Recursive search is available in tree view only", + "collapseAllDisabled": "Not available in list view", + "dragDrop": { + "unableToResolveRoot": "Unable to determine destination path for move.", + "moveUnsupported": "Move is not supported for this item.", + "createFolderHint": "Release to create new folder", + "newFolderName": "New folder name", + "folderNameHint": "Press Enter to confirm, Escape to cancel", + "emptyFolderName": "Please enter a folder name", + "invalidFolderName": "Folder name contains invalid characters", + "noDragState": "No pending drag operation found" }, - "support": { - "title": "Support the Project", - "message": "If you find LoRA Manager useful, I'd really appreciate your support! 🙌", - "feedback": { - "title": "Provide Feedback", - "description": "Your feedback helps shape future updates! Share your thoughts:" - }, - "links": { - "submitGithubIssue": "Submit GitHub Issue", - "joinDiscord": "Join Discord", - "youtubeChannel": "YouTube Channel", - "civitaiProfile": "Civitai Profile", - "supportKofi": "Support on Ko-fi", - "supportPatreon": "Support on Patreon" - }, - "sections": { - "followUpdates": "Follow for Updates", - "buyMeCoffee": "Buy me a coffee", - "coffeeDescription": "If you'd like to support my work directly:", - "becomePatron": "Become a Patron", - "patronDescription": "Support ongoing development with monthly contributions:", - "wechatSupport": "WeChat Support", - "wechatDescription": "For users in China, you can support via WeChat:", - "showWechatQR": "Show WeChat QR Code", - "hideWechatQR": "Hide WeChat QR Code" - }, - "footer": "Thank you for using LoRA Manager! ❤️", - "supporters": { - "title": "Thank You To Our Supporters", - "subtitle": "Thanks to {count} supporters who made this project possible", - "specialThanks": "Special Thanks", - "allSupporters": "All Supporters", - "totalCount": "{count} supporters in total" - } + "empty": { + "noFolders": "No folders found", + "dragHint": "Drag items here to create folders" }, - "toast": { - "general": { - "cannotInteractStandalone": "Cannot interact with ComfyUI in standalone mode", - "failedWorkflowInfo": "Failed to get workflow information", - "pageInitFailed": "Failed to initialize {pageType} page. Please reload.", - "statisticsLoadFailed": "Failed to load statistics data" - }, - "loras": { - "copyOnlyForLoras": "Copy syntax is only available for LoRAs", - "noLorasSelected": "No LoRAs selected", - "missingDataForLoras": "Missing data for {count} LoRAs", - "noValidLorasToCopy": "No valid LoRAs to copy", - "sendOnlyForLoras": "Send to workflow is only available for LoRAs", - "noValidLorasToSend": "No valid LoRAs to send", - "downloadSuccessful": "LoRAs downloaded successfully", - "allDownloadSuccessful": "All {count} LoRAs downloaded successfully", - "downloadPartialSuccess": "Downloaded {completed} of {total} LoRAs", - "downloadPartialWithAccess": "Downloaded {completed} of {total} LoRAs. {accessFailures} failed due to access restrictions. Check your API key in settings or early access status.", - "pleaseSelectVersion": "Please select a version", - "versionExists": "This version already exists in your library", - "downloadCompleted": "Download completed successfully", - "downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded", - "autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}", - "autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models", - "autoOrganizeFailed": "Auto-organize failed: {error}", - "noModelsSelected": "No models selected" - }, - "recipes": { - "fetchFailed": "Failed to fetch recipes: {message}", - "reloadFailed": "Failed to reload {modelType}s: {message}", - "loadFailed": "Failed to load {modelType}s: {message}", - "refreshComplete": "Refresh complete", - "refreshFailed": "Failed to refresh recipes: {message}", - "syncComplete": "Sync complete", - "syncFailed": "Failed to sync recipes: {message}", - "updateFailed": "Failed to update recipe: {error}", - "updateError": "Error updating recipe: {message}", - "nameSaved": "Recipe \"{name}\" saved successfully", - "nameUpdated": "Recipe name updated successfully", - "tagsUpdated": "Recipe tags updated successfully", - "sourceUrlUpdated": "Source URL updated successfully", - "promptUpdated": "Prompt updated successfully", - "negativePromptUpdated": "Negative prompt updated successfully", - "promptEditorHint": "Press Enter to save, Shift+Enter for new line", - "noRecipeId": "No recipe ID available", - "sendToWorkflowFailed": "Failed to send recipe to workflow: {message}", - "copyFailed": "Error copying recipe syntax: {message}", - "createError": "Error creating recipe: {message}", - "createFailed": "Failed to create recipe: {error}", - "createMissingData": "Missing required data to create recipe", - "created": "Recipe created successfully", - "noMissingLoras": "No missing LoRAs to download", - "missingLorasInfoFailed": "Failed to get information for missing LoRAs", - "preparingForDownloadFailed": "Error preparing LoRAs for download", - "enterLoraName": "Please enter a LoRA name or syntax", - "reconnectedSuccessfully": "LoRA reconnected successfully", - "reconnectFailed": "Error reconnecting LoRA: {message}", - "noPromptToSend": "No prompt to send", - "cannotSend": "Cannot send recipe: Missing recipe ID", - "sendFailed": "Failed to send recipe to workflow", - "sendError": "Error sending recipe to workflow", - "missingCheckpointPath": "Checkpoint path not available", - "missingCheckpointInfo": "Missing checkpoint information", - "downloadCheckpointFailed": "Failed to download checkpoint: {message}", - "cannotDelete": "Cannot delete recipe: Missing recipe ID", - "deleteConfirmationError": "Error showing delete confirmation", - "deletedSuccessfully": "Recipe deleted successfully", - "deleteFailed": "Error deleting recipe: {message}", - "cannotShare": "Cannot share recipe: Missing recipe ID", - "preparingForSharing": "Preparing recipe for sharing...", - "downloadStarted": "Recipe download started", - "shareError": "Error sharing recipe: {message}", - "sharePreparationError": "Error preparing recipe for sharing", - "selectImageFirst": "Please select an image first", - "enterRecipeName": "Please enter a recipe name", - "processingError": "Processing error: {message}", - "folderBrowserError": "Error loading folder browser: {message}", - "recipeSaveFailed": "Failed to save recipe: {error}", - "recipeSaved": "Recipe saved successfully", - "importFailed": "Import failed: {message}", - "folderTreeFailed": "Failed to load folder tree", - "folderTreeError": "Error loading folder tree", - "batchImportFailed": "Failed to start batch import: {message}", - "batchImportCancelling": "Cancelling batch import...", - "batchImportCancelFailed": "Failed to cancel batch import: {message}", - "batchImportNoUrls": "Please enter at least one URL or file path", - "batchImportNoDirectory": "Please enter a directory path", - "batchImportBrowseFailed": "Failed to browse directory: {message}", - "batchImportDirectorySelected": "Directory selected: {path}", - "noRecipesSelected": "No recipes selected", - "repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})", - "repairBulkSkipped": "No repair needed for any of the {total} selected recipes", - "repairBulkFailed": "Failed to repair selected recipes: {message}", - "reimporting": "Re-importing recipe from source...", - "reimportSuccess": "Recipe re-imported successfully", - "reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})", - "reimportBulkFailed": "Failed to re-import some recipes", - "noMissingLorasInSelection": "No missing LoRAs found in selected recipes", - "noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings." - }, - "models": { - "noModelsSelected": "No models selected", - "deletedSuccessfully": "Successfully deleted {count} {type}(s)", - "deleteFailed": "Error: {error}", - "deleteFailedGeneral": "Failed to delete models", - "selectedAdditional": "Selected {count} additional {type}(s)", - "marqueeSelectionComplete": "Selected {count} {type}(s) with marquee selection", - "refreshMetadataFailed": "Failed to refresh metadata", - "nameCannotBeEmpty": "Model name cannot be empty", - "nameUpdatedSuccessfully": "Model name updated successfully", - "nameUpdateFailed": "Failed to update model name", - "baseModelUpdated": "Base model updated successfully", - "baseModelUpdateFailed": "Failed to update base model", - "baseModelNotSelected": "Please select a base model", - "bulkBaseModelUpdating": "Updating base model for {count} model(s)...", - "bulkBaseModelUpdateSuccess": "Successfully updated base model for {count} model(s)", - "bulkBaseModelUpdatePartial": "Updated {success} model(s), failed {failed} model(s)", - "bulkBaseModelUpdateFailed": "Failed to update base model for selected models", - "skipMetadataRefreshUpdating": "Updating metadata refresh flag for {count} model(s)...", - "skipMetadataRefreshSet": "Metadata refresh skipped for {count} model(s)", - "skipMetadataRefreshCleared": "Metadata refresh resumed for {count} model(s)", - "skipMetadataRefreshPartial": "Updated {success} model(s), {failed} failed", - "skipMetadataRefreshFailed": "Failed to update metadata refresh flag for selected models", - "bulkContentRatingUpdating": "Updating content rating for {count} model(s)...", - "bulkContentRatingSet": "Set content rating to {level} for {count} model(s)", - "bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed", - "bulkContentRatingFailed": "Failed to update content rating for selected models", - "bulkFavoriteUpdating": "Adding {count} model(s) to favorites...", - "bulkUnfavoriteUpdating": "Removing {count} model(s) from favorites...", - "bulkFavoritePartialAdded": "Added {success} model(s) to favorites, {failed} failed", - "bulkFavoritePartialRemoved": "Removed {success} model(s) from favorites, {failed} failed", - "bulkFavoriteFailed": "Failed to update favorite status for selected models", - "bulkUpdatesChecking": "Checking selected {type}(s) for updates...", - "bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)", - "bulkUpdatesNone": "No updates found for selected {type}(s)", - "bulkUpdatesMissing": "Selected {type}(s) are not linked to Civitai updates", - "bulkUpdatesPartialMissing": "Skipped {missing} selected {type}(s) without Civitai links", - "bulkUpdatesFailed": "Failed to check updates for selected {type}(s): {message}", - "invalidCharactersRemoved": "Invalid characters removed from filename", - "filenameCannotBeEmpty": "File name cannot be empty", - "renameFailed": "Failed to rename file: {message}", - "moveFailed": "Failed to move model(s): {message}", - "pleaseSelectRoot": "Please select a {type} root directory", - "nameTooLong": "Model name is limited to 100 characters", - "verificationAlreadyDone": "This group has already been verified", - "verificationCompleteMismatch": "Verification complete. {count} file(s) have different actual hashes.", - "verificationCompleteSuccess": "Verification complete. All files are confirmed duplicates.", - "verificationFailed": "Failed to verify hashes: {message}", - "noTagsToAdd": "No tags to add", - "bulkTagsUpdating": "Updating tags for {count} model(s)...", - "tagsAddedSuccessfully": "Successfully added {tagCount} tag(s) to {count} {type}(s)", - "tagsReplacedSuccessfully": "Successfully replaced tags for {count} {type}(s) with {tagCount} tag(s)", - "tagsAddFailed": "Failed to add tags to {count} model(s)", - "tagsReplaceFailed": "Failed to replace tags for {count} model(s)", - "bulkTagsAddFailed": "Failed to add tags to models", - "bulkTagsReplaceFailed": "Failed to replace tags for models" - }, - "search": { - "atLeastOneOption": "At least one search option must be selected" - }, - "settings": { - "loraRootsFailed": "Failed to load LoRA roots: {message}", - "checkpointRootsFailed": "Failed to load checkpoint roots: {message}", - "unetRootsFailed": "Failed to load diffusion model roots: {message}", - "embeddingRootsFailed": "Failed to load embedding roots: {message}", - "mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})", - "mappingsCleared": "Base model path mappings cleared", - "mappingSaveFailed": "Failed to save base model mappings: {message}", - "downloadTemplatesUpdated": "Download path templates updated", - "downloadTemplatesFailed": "Failed to save download path templates: {message}", - "recipesPathUpdated": "Recipes storage path updated", - "recipesPathSaveFailed": "Failed to update recipes storage path: {message}", - "settingsUpdated": "Settings updated: {setting}", - "compactModeToggled": "Compact Mode {state}", - "settingSaveFailed": "Failed to save setting: {message}", - "displayDensitySet": "Display Density set to {density}", - "libraryLoadFailed": "Failed to load libraries: {message}", - "libraryActivateFailed": "Failed to activate library: {message}", - "languageChangeFailed": "Failed to change language: {message}", - "cacheCleared": "Cache files have been cleared successfully. Cache will rebuild on next action.", - "cacheClearFailed": "Failed to clear cache: {error}", - "cacheClearError": "Error clearing cache: {message}" - }, - "filters": { - "applied": "{message}", - "cleared": "Filters cleared", - "noCustomFilterToClear": "No custom filter to clear", - "noActiveFilters": "No active filters to save" - }, - "presets": { - "created": "Preset \"{name}\" created", - "deleted": "Preset \"{name}\" deleted", - "applied": "Preset \"{name}\" applied", - "overwritten": "Preset \"{name}\" overwritten", - "restored": "Default presets restored" - }, - "error": { - "presetNameEmpty": "Preset name cannot be empty", - "presetNameTooLong": "Preset name must be {max} characters or less", - "presetNameInvalidChars": "Preset name contains invalid characters", - "presetNameExists": "A preset with this name already exists", - "maxPresetsReached": "Maximum {max} presets allowed. Delete one to add more.", - "presetNotFound": "Preset not found", - "invalidPreset": "Invalid preset data", - "deletePresetFailed": "Failed to delete preset", - "applyPresetFailed": "Failed to apply preset" - }, - "downloads": { - "imagesCompleted": "Example images {action} completed", - "imagesFailed": "Example images {action} failed", - "loadError": "Error loading downloads: {message}", - "downloadError": "Download error: {message}" - }, - "import": { - "folderTreeFailed": "Failed to load folder tree", - "folderTreeError": "Error loading folder tree", - "imagesImported": "Example images imported successfully", - "imagesPartial": "{success} image(s) imported, {failed} failed", - "importFailed": "Failed to import example images: {message}" - }, - "triggerWords": { - "loadFailed": "Could not load trained words", - "tooLong": "Trigger word should not exceed 500 words", - "tooMany": "Maximum 100 trigger words allowed", - "alreadyExists": "This trigger word already exists", - "updateSuccess": "Trigger words updated successfully", - "updateFailed": "Failed to update trigger words", - "copyFailed": "Copy failed" - }, - "virtual": { - "loadFailed": "Failed to load items", - "loadMoreFailed": "Failed to load more items", - "loadPositionFailed": "Failed to load items at this position" - }, - "bulk": { - "unableToSelectAll": "Unable to select all items" - }, - "duplicates": { - "findFailed": "Failed to find duplicates: {message}", - "noDuplicatesFound": "No duplicate {type} found", - "noItemsSelected": "No {type} selected for deletion", - "deleteError": "Error: {message}", - "deleteSuccess": "Successfully deleted {count} {type}", - "deleteFailed": "Failed to delete {type}: {message}" - }, - "controls": { - "reloadFailed": "Failed to reload {pageType}: {message}", - "refreshFailed": "Failed to {action} {pageType}: {message}", - "fetchMetadataFailed": "Failed to fetch metadata: {message}", - "clearFilterFailed": "Failed to clear custom filter: {message}" - }, - "contextMenu": { - "contentRatingSet": "Content rating set to {level}", - "contentRatingFailed": "Failed to set content rating: {message}", - "relinkSuccess": "Model successfully re-linked to Civitai", - "relinkFailed": "Error: {message}", - "fetchMetadataFirst": "Please fetch metadata from CivitAI first", - "noCivitaiInfo": "No CivitAI information available", - "missingHash": "Model hash not available" - }, - "exampleImages": { - "pathUpdated": "Example images path updated successfully", - "pathUpdateFailed": "Failed to update example images path: {message}", - "downloadInProgress": "Download already in progress", - "enterLocationFirst": "Please enter a download location first", - "downloadStarted": "Example images download started", - "downloadStartFailed": "Failed to start download: {error}", - "downloadPaused": "Download paused", - "pauseFailed": "Failed to pause download: {error}", - "downloadResumed": "Download resumed", - "resumeFailed": "Failed to resume download: {error}", - "downloadStopped": "Download cancelled", - "stopFailed": "Failed to cancel download: {error}", - "deleted": "Example image deleted", - "deleteFailed": "Failed to delete example image", - "setPreviewFailed": "Failed to set preview image" - }, - "api": { - "fetchFailed": "Failed to fetch {type}s: {message}", - "reloadFailed": "Failed to reload {type}s: {message}", - "deleteSuccess": "{type} deleted successfully", - "deleteFailed": "Failed to delete {type}: {message}", - "excludeSuccess": "{type} excluded successfully", - "excludeFailed": "Failed to exclude {type}: {message}", - "restoreSuccess": "{type} restored successfully", - "restoreFailed": "Failed to restore {type}: {message}", - "fileNameUpdated": "File name updated successfully", - "fileRenameFailed": "Failed to rename file: {error}", - "previewUpdated": "Preview updated successfully", - "previewUploadFailed": "Failed to upload preview image", - "refreshComplete": "{action} complete", - "refreshFailed": "Failed to {action} {type}s", - "metadataRefreshed": "Metadata refreshed successfully", - "metadataRefreshFailed": "Failed to refresh metadata: {message}", - "metadataUpdateComplete": "Metadata update complete", - "operationCancelled": "Operation cancelled by user", - "operationCancelledPartial": "Operation cancelled. {success} items processed.", - "metadataFetchFailed": "Failed to fetch metadata: {message}", - "bulkMetadataCompleteAll": "Successfully refreshed all {count} {type}s", - "bulkMetadataCompletePartial": "Refreshed {success} of {total} {type}s", - "bulkMetadataCompleteNone": "Failed to refresh metadata for any {type}s", - "bulkMetadataFailureDetails": "Failed refreshes:\n{failures}", - "bulkMetadataFailed": "Failed to refresh metadata: {message}", - "moveNotSupported": "Moving {type}s is not supported", - "alreadyInFolder": "{type} is already in the selected folder", - "moveInfo": "{message}", - "moveSuccess": "{type} moved successfully", - "bulkMoveNotSupported": "Moving {type}s is not supported", - "allAlreadyInFolder": "All selected {type}s are already in the target folder", - "bulkMovePartial": "Moved {successCount} {type}s, {failureCount} failed", - "bulkMoveFailures": "Failed moves:\n{failures}", - "bulkMoveSuccess": "Successfully moved {successCount} {type}s", - "exampleImagesDownloadSuccess": "Successfully downloaded example images!", - "exampleImagesDownloadFailed": "Failed to download example images: {message}", - "moveFailed": "Failed to move item: {message}", - "copiedToClipboard": "Copied to clipboard", - "downloadStarted": "Download started" - } + "folderUpdateCheck": { + "label": "Check for updates in this folder", + "loading": "Checking {type} updates for this folder...", + "success": "Found {count} update(s) for {type}s in this folder", + "none": "All {type}s in this folder are up to date", + "error": "Failed to check folder for {type} updates: {message}" + } + }, + "statistics": { + "title": "Statistics", + "tabs": { + "overview": "Overview", + "usage": "Usage Analysis", + "collection": "Collection", + "storage": "Storage", + "insights": "Insights" }, - "doctor": { - "kicker": "System diagnostics", - "title": "Doctor", - "buttonTitle": "Run diagnostics and common fixes", - "loading": "Checking environment...", - "footer": "Export a diagnostics bundle if the issue still persists after repair.", - "summary": { - "idle": "Run a health check for settings, cache integrity, and UI consistency.", - "ok": "No active issues were found in the current environment.", - "warning": "{count} issue(s) were found. Most can be fixed directly from this panel.", - "error": "{count} issue(s) need attention before the app is fully healthy." + "metrics": { + "totalModels": "Total Models", + "totalStorage": "Total Storage", + "totalGenerations": "Total Generations", + "usageRate": "Usage Rate", + "loras": "LoRAs", + "checkpoints": "Checkpoints", + "embeddings": "Embeddings", + "uniqueTags": "Unique Tags", + "unusedModels": "Unused Models", + "avgUsesPerModel": "Avg. Uses/Model" + }, + "usage": { + "mostUsedLoras": "Most Used LoRAs", + "mostUsedCheckpoints": "Most Used Checkpoints", + "mostUsedEmbeddings": "Most Used Embeddings" + }, + "collection": { + "popularTags": "Popular Tags", + "modelTypes": "Model Types", + "collectionAnalysis": "Collection Analysis" + }, + "storage": { + "storageUsage": "Storage Usage", + "largestModels": "Largest Models", + "storageEfficiency": "Storage vs Usage Efficiency" + }, + "insights": { + "smartInsights": "Smart Insights", + "recommendations": "Recommendations", + "noInsights": "No insights available", + "unusedLoras": { + "high": { + "title": "High Number of Unused LoRAs", + "description": "{percent}% of your LoRAs ({count}/{total}) have never been used.", + "suggestion": "Consider organizing or archiving unused models to free up storage space." + } + }, + "unusedCheckpoints": { + "detected": { + "title": "Unused Checkpoints Detected", + "description": "{percent}% of your checkpoints ({count}/{total}) have never been used.", + "suggestion": "Review and consider removing checkpoints you no longer need." + } + }, + "unusedEmbeddings": { + "high": { + "title": "High Number of Unused Embeddings", + "description": "{percent}% of your embeddings ({count}/{total}) have never been used.", + "suggestion": "Consider organizing or archiving unused embeddings to optimize your collection." + } + }, + "collection": { + "large": { + "title": "Large Collection Detected", + "description": "Your model collection is using {size} of storage.", + "suggestion": "Consider using external storage or cloud solutions for better organization." + } + }, + "activity": { + "active": { + "title": "Active User", + "description": "You've completed {count} generations so far!", + "suggestion": "Keep exploring and creating amazing content with your models." + } + } + }, + "charts": { + "collectionOverview": "Collection Overview", + "baseModelDistribution": "Base Model Distribution", + "usageTrends": "Usage Trends (Last 30 Days)", + "usageDistribution": "Usage Distribution", + "date": "Date", + "usageCount": "Usage Count", + "fileSizeBytes": "File Size (bytes)", + "models": "Models", + "loraUsage": "LoRA Usage", + "checkpointUsage": "Checkpoint Usage", + "embeddingUsage": "Embedding Usage" + }, + "modelTypes": { + "lora": "LoRA", + "locon": "LyCORIS", + "dora": "DoRA", + "checkpoint": "Checkpoint", + "diffusion_model": "Diffusion Model", + "embedding": "Embeddings" + }, + "placeholders": { + "loading": "Loading...", + "noModels": "No models found", + "errorLoading": "Error loading data", + "noStorageData": "No storage data available", + "rootFolder": "Root", + "chartLibraryMissing": "Chart requires Chart.js library" + }, + "tooltips": { + "tagCount": "{tag}: {count} models", + "chartUsage": "{name}: {size}, {count} uses", + "chartPercentage": "{label}: {value} ({pct}%)" + } + }, + "modals": { + "exclude": { + "confirm": "Exclude" + }, + "download": { + "title": "Download Model from URL", + "titleWithType": "Download {type} from URL", + "civitaiUrl": "Civitai URL(s):", + "placeholder": "https://civitai.com/models/...", + "urlHint": "Enter one CivitAI, CivArchive, or Hugging Face URL per line. Supports multiple URLs for batch download.", + "selectHfFiles": "Select file(s) to download from this repository:", + "selectAll": "Select All", + "fetchingRepoFiles": "Fetching repository files...", + "locationPreview": "Download Location Preview", + "useDefaultPath": "Use Default Path", + "useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates", + "selectRootDirectory": "Select a root directory", + "selectModelRoot": "Select Model Root:", + "selectTypeRoot": "Select {type} Root:", + "targetFolderPath": "Target Folder Path:", + "browseFolders": "Browse Folders:", + "createNewFolder": "Create new folder", + "pathPlaceholder": "Type folder path or select from tree below...", + "root": "Root", + "download": "Download", + "fetchingVersions": "Fetching model versions...", + "versionPreview": "Version preview", + "earlyAccess": "Early Access", + "earlyAccessTooltip": "Early access required", + "inLibrary": "In Library", + "downloaded": "Downloaded", + "downloadedTooltip": "Previously downloaded, but it is not currently in your library.", + "alreadyInLibrary": "Already in Library", + "autoOrganizedPath": "[Auto-organized by path template]", + "fileSelection": { + "title": "Select File Format", + "files": "files", + "select": "Select File" + }, + "errors": { + "invalidUrl": "Invalid Civitai URL format", + "noVersions": "No versions available for this model", + "mixedSources": "Cannot mix CivitAI and Hugging Face URLs in the same batch.", + "noModelFiles": "No model files found in this repository." + }, + "status": { + "preparing": "Preparing download...", + "downloadedPreview": "Downloaded preview image", + "downloadingFile": "Downloading {type} file", + "finalizing": "Finalizing download..." + }, + "progress": { + "currentFile": "Current file:", + "downloading": "Downloading: {name}", + "transferred": "Transferred: {downloaded} / {total}", + "transferredSimple": "Transferred: {downloaded}", + "transferredUnknown": "Transferred: --", + "speed": "Speed: {speed}" + } + }, + "move": { + "title": "Move Models" + }, + "contentRating": { + "title": "Set Content Rating", + "current": "Current", + "multiple": "Multiple values", + "levels": { + "pg": "PG", + "pg13": "PG13", + "r": "R", + "x": "X", + "xxx": "XXX" + } + }, + "deleteModel": { + "title": "Delete Model", + "message": "Are you sure you want to delete this model and all associated files?" + }, + "excludeModel": { + "title": "Exclude Model", + "message": "Are you sure you want to exclude this model? Excluded models won't appear in searches or model lists." + }, + "deleteDuplicateRecipes": { + "title": "Delete Duplicate Recipes", + "message": "Are you sure you want to delete the selected duplicate recipes?", + "countMessage": "recipes will be permanently deleted." + }, + "deleteDuplicateModels": { + "title": "Delete Duplicate Models", + "message": "Are you sure you want to delete the selected duplicate models?", + "countMessage": "models will be permanently deleted." + }, + "clearCache": { + "title": "Clear Cache Files", + "message": "Are you sure you want to clear all cache files?", + "description": "This will remove all cached model data. The system will need to rebuild the cache on next startup, which may take some time depending on your model collection size.", + "action": "Clear Cache" + }, + "bulkDelete": { + "title": "Delete Multiple Models", + "message": "Are you sure you want to delete all selected models and their associated files?", + "countMessage": "models will be permanently deleted.", + "action": "Delete All" + }, + "bulkDeleteRecipes": { + "title": "Delete Multiple Recipes", + "message": "Are you sure you want to delete all selected recipes and their associated files?", + "countMessage": "recipes will be permanently deleted.", + "action": "Delete All" + }, + "checkUpdates": { + "title": "Check updates for all {typePlural}?", + "message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.", + "tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".", + "action": "Check All" + }, + "bulkAddTags": { + "title": "Add Tags to Multiple Models", + "description": "Add tags to", + "models": "models", + "tagsToAdd": "Tags to Add", + "placeholder": "Enter tag and press Enter...", + "appendTags": "Append Tags", + "replaceTags": "Replace Tags", + "saveChanges": "Save changes" + }, + "bulkBaseModel": { + "title": "Set Base Model for Multiple Models", + "description": "Set base model for", + "models": "models", + "selectBaseModel": "Select Base Model", + "save": "Update Base Model", + "cancel": "Cancel" + }, + "bulkDownloadMissingLoras": { + "title": "Download Missing LoRAs", + "message": "Found {uniqueCount} unique missing LoRAs (from {totalCount} total across selected recipes).", + "previewTitle": "LoRAs to download:", + "moreItems": "...and {count} more", + "note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.", + "downloadButton": "Download {count} LoRA(s)" + }, + "exampleAccess": { + "title": "Local Example Images", + "message": "No local example images found for this model. View options:", + "downloadOption": { + "title": "Download from Civitai", + "description": "Save remote examples locally for offline use and faster loading" + }, + "importOption": { + "title": "Import Your Own", + "description": "Add your own custom examples for this model" + }, + "footerNote": "Remote examples are still viewable in the model details even without local copies" + }, + "moveModel": { + "targetLocationPreview": "Target Location Preview:", + "selectModelRoot": "Select Model Root:", + "targetFolderPath": "Target Folder Path:", + "browseFolders": "Browse Folders:", + "createNewFolder": "Create new folder", + "pathPlaceholder": "Type folder path or select from tree below...", + "root": "Root" + }, + "relinkCivitai": { + "title": "Re-link to Civitai", + "warning": "Warning:", + "warningText": "This is a potentially destructive operation. Re-linking will:", + "warningList": { + "overrideMetadata": "Override existing metadata", + "modifyHash": "Potentially modify the model hash", + "unintendedConsequences": "May have other unintended consequences" + }, + "proceedText": "Only proceed if you're sure this is what you want.", + "urlLabel": "Civitai Model URL:", + "urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 or https://civitai.red/models/649516/model-name?modelVersionId=726676", + "helpText": { + "title": "Paste any Civitai model URL from civitai.com or civitai.red. Supported formats:", + "format1": "https://civitai.com/models/649516", + "format2": "https://civitai.com/models/649516?modelVersionId=726676", + "format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676", + "note": "Note: If no modelVersionId is provided, the latest version will be used." + }, + "confirmAction": "Confirm Re-link" + }, + "model": { + "actions": { + "editModelName": "Edit model name", + "editFileName": "Edit file name", + "editBaseModel": "Edit base model", + "editVersionName": "Edit version name", + "viewOnCivitai": "View on Civitai", + "viewOnCivitaiText": "View on Civitai", + "viewOnHuggingFace": "View on Hugging Face", + "viewOnHuggingFaceText": "View on Hugging Face", + "viewCreatorProfile": "View Creator Profile", + "openFileLocation": "Open File Location", + "sendToWorkflow": "Send to ComfyUI", + "sendToWorkflowText": "Send to ComfyUI" + }, + "openFileLocation": { + "success": "File location opened successfully", + "failed": "Failed to open file location", + "copied": "Path copied to clipboard: {{path}}", + "clipboardFallback": "Path: {{path}}" + }, + "sendToWorkflow": { + "noFilePath": "Unable to send to ComfyUI: No file path available" + }, + "metadata": { + "version": "Version", + "fileName": "File Name", + "location": "Location", + "baseModel": "Base Model", + "size": "Size", + "unknown": "Unknown", + "usageTips": "Usage Tips", + "additionalNotes": "Additional Notes", + "notesHint": "Press Enter to save, Shift+Enter for new line", + "addNotesPlaceholder": "Add your notes here...", + "aboutThisVersion": "About this version", + "baseModelSearchPlaceholder": "Search base model…", + "baseModelSuggested": "Suggested", + "baseModelNoMatch": "No matching base models" + }, + "notes": { + "saved": "Notes saved successfully", + "saveFailed": "Failed to save notes", + "showMore": "Show more", + "showLess": "Show less" + }, + "usageTips": { + "addPresetParameter": "Add preset parameter...", + "strengthMin": "Strength Min", + "strengthMax": "Strength Max", + "strengthRange": "Strength Range", + "strength": "Strength", + "clipStrength": "Clip Strength", + "clipSkip": "Clip Skip", + "valuePlaceholder": "Value", + "add": "Add", + "invalidRange": "Invalid range format. Use x.x-y.y" + }, + "triggerWords": { + "label": "Trigger Words", + "noTriggerWordsNeeded": "No trigger word needed", + "edit": "Edit trigger words", + "cancel": "Cancel editing", + "save": "Save changes", + "addPlaceholder": "Type to add or click suggestions below", + "editWord": "Edit trigger word", + "editPlaceholder": "Edit trigger word", + "copyWord": "Copy trigger word", + "deleteWord": "Delete trigger word", + "suggestions": { + "noSuggestions": "No suggestions available", + "noTrainedWords": "No trained words or class tokens found in this model. You can manually enter trigger words.", + "classToken": "Class Token", + "classTokenDescription": "Add to your prompt for best results", + "wordSuggestions": "Word Suggestions", + "wordsFound": "{count} words found", + "loading": "Loading suggestions..." + } + }, + "description": { + "noDescription": "No model description available", + "failedToLoad": "Failed to load model description", + "editTitle": "Edit model description", + "validation": { + "cannotBeEmpty": "Description cannot be empty" }, - "status": { - "ok": "Healthy", - "warning": "Needs Attention", - "error": "Action Required" - }, - "issues": { - "civitai_api_key": { - "title": "Civitai API Key" - }, - "cache_health": { - "title": "Model Cache Health" - }, - "filename_conflicts": { - "title": "Duplicate Filename Conflicts" - }, - "ui_version": { - "title": "UI Version" - } - }, - "actions": { - "runAgain": "Run Again", - "exportBundle": "Export Bundle", - "open-settings": "Open Settings", - "open-settings-syntax-format": "Switch to Full Path Syntax", - "repair-cache": "Rebuild Cache", - "resolve-filename-conflicts": "Resolve Conflicts", - "reload-page": "Reload UI" + "messages": { + "updated": "Model description updated", + "updateFailed": "Failed to update model description" + } + }, + "tabs": { + "examples": "Examples", + "description": "Model Description", + "recipes": "Recipes", + "versions": "Versions" + }, + "navigation": { + "label": "Model navigation", + "previousWithShortcut": "Previous model (←)", + "nextWithShortcut": "Next model (→)", + "noPrevious": "No previous model available", + "noNext": "No next model available" + }, + "license": { + "noImageSell": "No selling generated content", + "noRentCivit": "No Civitai generation", + "noRent": "No generation services", + "noSell": "No selling models", + "creditRequired": "Creator credit required", + "noDerivatives": "No sharing merges", + "noReLicense": "Same permissions required", + "restrictionsLabel": "License restrictions" + }, + "loading": { + "exampleImages": "Loading example images...", + "description": "Loading model description...", + "recipes": "Loading recipes...", + "examples": "Loading examples...", + "versions": "Loading versions..." + }, + "versions": { + "heading": "Model versions", + "copy": "Track and manage every version of this model in one place.", + "media": { + "placeholder": "No preview" }, "labels": { - "conflicts": "Conflicts", - "version": "Version" + "unnamed": "Untitled Version", + "noDetails": "No additional details", + "earlyAccess": "EA" + }, + "eaTime": { + "endingSoon": "ending soon", + "hours": "in {count}h", + "days": "in {count}d" + }, + "badges": { + "current": "Opened Version", + "currentTooltip": "This is the version you opened this modal from", + "inLibrary": "In Library", + "inLibraryTooltip": "This version exists in your local library", + "downloaded": "Downloaded", + "downloadedTooltip": "This version was downloaded before, but is not currently in your library", + "newer": "Newer Version", + "newerTooltip": "This version is newer than your latest local version", + "earlyAccess": "Early Access", + "earlyAccessTooltip": "This version currently requires Civitai early access", + "ignored": "Ignored", + "ignoredTooltip": "Update notifications are disabled for this version", + "onSiteOnly": "On-Site Only", + "onSiteOnlyTooltip": "This version is only available for on-site generation on Civitai" + }, + "actions": { + "download": "Download", + "downloadTooltip": "Download this version", + "downloadEarlyAccessTooltip": "Download this early access version from Civitai", + "downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai", + "delete": "Delete", + "deleteTooltip": "Delete this local version", + "ignore": "Ignore", + "unignore": "Unignore", + "ignoreTooltip": "Ignore update notifications for this version", + "unignoreTooltip": "Resume update notifications for this version", + "viewVersionOnCivitai": "View version on Civitai", + "earlyAccessTooltip": "Requires early access purchase", + "resumeModelUpdates": "Resume updates for this model", + "ignoreModelUpdates": "Ignore updates for this model", + "viewLocalVersions": "View all local versions", + "viewLocalTooltip": "Show all local versions of this model on the main page" + }, + "filters": { + "label": "Base filter", + "state": { + "showAll": "All versions", + "showSameBase": "Same base" + }, + "tooltip": { + "showAllVersions": "Switch to showing all versions", + "showSameBaseVersions": "Switch to showing only versions that match the current base model" + }, + "empty": "No versions match the current base model filter." + }, + "empty": "No version history available for this model yet.", + "error": "Failed to load versions.", + "missingModelId": "This model is missing a Civitai model id.", + "confirm": { + "delete": "Delete this version from your library?" }, "toast": { - "loadFailed": "Failed to load diagnostics: {message}", - "repairSuccess": "Cache rebuild completed.", - "repairFailed": "Cache rebuild failed: {message}", - "exportSuccess": "Diagnostics bundle exported.", - "exportFailed": "Failed to export diagnostics bundle: {message}", - "conflictsResolved": "{count} filename conflict(s) resolved.", - "conflictsResolveFailed": "Failed to resolve filename conflicts: {message}" + "modelIgnored": "Updates ignored for this model", + "modelResumed": "Update tracking resumed", + "versionIgnored": "Updates ignored for this version", + "versionUnignored": "Version re-enabled", + "versionDeleted": "Version deleted" } + } }, - "conflictConfirm": { - "title": "Resolve Filename Conflicts", - "message": "Renaming by appending a 4-character hash to each duplicate filename.", - "note": "This operation renames files on disk. Model references in existing workflows may need updating if you use the A1111 syntax format.", - "detail": "Example: filename_v1.2filename_v1.2-ab3c", - "impact": "Will rename {count} file(s) across {groups} duplicate group(s).", - "confirm": "Rename Files", - "cancel": "Cancel" + "metadataFetchSummary": { + "title": "Metadata Fetch Summary", + "statSuccess": "Success", + "statFailed": "Failed", + "statSkipped": "Skipped", + "statTotal": "Total Scanned", + "statDuration": "Duration", + "successMessage": "All {count} {type}s updated successfully!", + "failedItems": "Failed Items ({count})", + "close": "Close", + "copyReport": "Copy Report", + "downloadCsv": "Download CSV", + "columnModelName": "Model Name", + "columnError": "Error" + } + }, + "modelTags": { + "messages": { + "updated": "Tags updated successfully", + "updateFailed": "Failed to update tags" + }, + "validation": { + "maxLength": "Tag should not exceed 30 characters", + "maxCount": "Maximum 30 tags allowed", + "duplicate": "This tag already exists" + } + }, + "initialization": { + "title": "Initializing", + "message": "Preparing your workspace...", + "status": "Initializing...", + "estimatingTime": "Estimating time...", + "loras": { + "title": "Initializing LoRA Manager", + "message": "Scanning and building LoRA cache. This may take a few minutes..." + }, + "checkpoints": { + "title": "Initializing Checkpoint Manager", + "message": "Scanning and building checkpoint cache. This may take a few minutes..." + }, + "embeddings": { + "title": "Initializing Embedding Manager", + "message": "Scanning and building embedding cache. This may take a few minutes..." + }, + "recipes": { + "title": "Initializing Recipe Manager", + "message": "Loading and processing recipes. This may take a few minutes..." + }, + "statistics": { + "title": "Initializing Statistics", + "message": "Processing model data for statistics. This may take a few minutes..." + }, + "tips": { + "title": "Tips & Tricks", + "civitai": { + "title": "Civitai Integration", + "description": "Connect your Civitai account: Visit Profile Avatar → Settings → API Keys → Add API Key, then paste it in Lora Manager settings.", + "alt": "Civitai API Setup" + }, + "download": { + "title": "Easy Download", + "description": "Use Civitai URLs to quickly download and install new models.", + "alt": "Civitai Download" + }, + "recipes": { + "title": "Save Recipes", + "description": "Create recipes to save your favorite model combinations for future use.", + "alt": "Recipes" + }, + "filter": { + "title": "Fast Filtering", + "description": "Filter models by tags or base model type using the filter button in the header.", + "alt": "Filter Models" + }, + "search": { + "title": "Quick Search", + "description": "Press Ctrl+F (Cmd+F on Mac) to quickly search within your current view.", + "alt": "Quick Search" + } + } + }, + "duplicates": { + "found": "Found {count} duplicate groups", + "showNotification": "Show Duplicates Notification", + "deleteSelected": "Delete Selected", + "exitMode": "Exit Mode", + "help": { + "identicalHashes": "Identical hashes mean identical model files, even if they have different names or previews.", + "keepOne": "Keep only one version (preferably with better metadata/previews) and safely delete the others." + } + }, + "uiHelpers": { + "clipboard": { + "copied": "Copied to clipboard", + "copyFailed": "Copy failed" + }, + "lora": { + "syntaxCopied": "LoRA syntax copied to clipboard", + "syntaxCopiedNoTriggerWords": "LoRA syntax copied to clipboard (no trigger words found)", + "syntaxCopiedWithTriggerWords": "LoRA syntax with trigger words copied to clipboard", + "syntaxCopiedWithTriggerWordGroups": "LoRA syntax with trigger word groups copied to clipboard" + }, + "workflow": { + "noSupportedNodes": "No supported target nodes found in workflow", + "communicationFailed": "Failed to communicate with ComfyUI", + "loraAdded": "LoRA appended to workflow", + "loraReplaced": "LoRA replaced in workflow", + "loraFailedToSend": "Failed to send LoRA to workflow", + "recipeAdded": "Recipe appended to workflow", + "recipeReplaced": "Recipe replaced in workflow", + "recipeFailedToSend": "Failed to send recipe to workflow", + "noMatchingNodes": "No compatible nodes available in the current workflow", + "noTargetNodeSelected": "No target node selected", + "modelUpdated": "Model updated in workflow", + "modelFailed": "Failed to update model node", + "embeddingAdded": "Embedding added to workflow", + "embeddingFailed": "Failed to add embedding", + "promptSent": "Prompt sent to workflow", + "promptFailed": "Failed to send prompt" + }, + "nodeSelector": { + "recipe": "Recipe", + "lora": "LoRA", + "embedding": "Embedding", + "prompt": "Prompt", + "replace": "Replace", + "append": "Append", + "selectTargetNode": "Select target node", + "sendToAll": "Send to All" + }, + "exampleImages": { + "opened": "Example images folder opened", + "openingFolder": "Opening example images folder", + "failedToOpen": "Failed to open example images folder", + "copiedPath": "Path copied to clipboard: {{path}}", + "clipboardFallback": "Path: {{path}}", + "copiedUri": "Link copied to clipboard: {{uri}}", + "uriClipboardFallback": "Link: {{uri}}", + "setupRequired": "Example Images Storage", + "setupDescription": "To add custom example images, you need to set a download location first.", + "setupUsage": "This path is used for both downloaded and custom example images.", + "openSettings": "Open Settings" + } + }, + "help": { + "title": "Help & Tutorials", + "tabs": { + "gettingStarted": "Getting Started", + "updateVlogs": "Update Vlogs", + "documentation": "Documentation" + }, + "gettingStarted": { + "title": "Getting Started with LoRA Manager" + }, + "updateVlogs": { + "title": "Latest Updates", + "watchOnYouTube": "Watch on YouTube", + "playlistTitle": "LoRA Manager Updates Playlist", + "playlistDescription": "Watch all update videos showcasing the latest features and improvements." + }, + "documentation": { + "title": "Documentation", + "general": "General", + "troubleshooting": "Troubleshooting", + "modelManagement": "Model Management", + "recipes": "Recipes", + "settings": "Settings & Configuration", + "extensions": "Extensions", + "newBadge": "NEW" + } + }, + "update": { + "title": "Check for Updates", + "notificationsTitle": "Notifications", + "tabs": { + "updates": "Updates", + "messages": "Messages" + }, + "updateAvailable": "Update Available", + "noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.", + "currentVersion": "Current Version", + "newVersion": "New Version", + "commit": "Commit", + "viewOnGitHub": "View on GitHub", + "updateNow": "Update Now", + "preparingUpdate": "Preparing update...", + "changelog": "Changelog", + "checkingUpdates": "Checking for updates...", + "checkingMessage": "Please wait while we check for the latest version.", + "showNotifications": "Show update notifications", + "latestBadge": "Latest", + "updateProgress": { + "preparing": "Preparing update...", + "installing": "Installing update...", + "completed": "Update completed successfully!", + "failed": "Update failed: {error}" + }, + "status": { + "updating": "Updating...", + "updated": "Updated!", + "updateFailed": "Update Failed" + }, + "completion": { + "successMessage": "Successfully updated to {version}!", + "restartMessage": "Please restart ComfyUI or LoRA Manager to apply update.", + "reloadMessage": "Make sure to reload your browser for both LoRA Manager and ComfyUI." + }, + "nightly": { + "warning": "Warning: Nightly builds may contain experimental features and could be unstable.", + "enable": "Enable Nightly Updates" }, "banners": { - "versionMismatch": { - "title": "Application Update Detected", - "content": "Your browser is running an outdated version of LoRA Manager ({storedVersion}). The server has been updated to version {currentVersion}. Please refresh to ensure proper functionality.", - "refreshNow": "Refresh Now", - "refreshingIn": "Refreshing in", - "seconds": "seconds" - }, - "communitySupport": { - "title": "Keep LoRA Manager Thriving with Your Support ❤️", - "content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.", - "supportCta": "Support on Ko-fi", - "learnMore": "LM Civitai Extension Tutorial" - }, - "cacheHealth": { - "corrupted": { - "title": "Cache Corruption Detected" - }, - "degraded": { - "title": "Cache Issues Detected" - }, - "content": "{invalid} of {total} cache entries are invalid ({rate}). This may cause missing models or errors. Rebuilding the cache is recommended.", - "rebuildCache": "Rebuild Cache", - "dismiss": "Dismiss", - "rebuilding": "Rebuilding cache...", - "rebuildFailed": "Failed to rebuild cache: {error}", - "retry": "Retry" - } + "recent": "Recent messages", + "empty": "No recent banners yet.", + "shown": "Shown {time}", + "dismissed": "Dismissed {time}", + "active": "Active" } -} + }, + "support": { + "title": "Support the Project", + "message": "If you find LoRA Manager useful, I'd really appreciate your support! 🙌", + "feedback": { + "title": "Provide Feedback", + "description": "Your feedback helps shape future updates! Share your thoughts:" + }, + "links": { + "submitGithubIssue": "Submit GitHub Issue", + "joinDiscord": "Join Discord", + "youtubeChannel": "YouTube Channel", + "civitaiProfile": "Civitai Profile", + "supportKofi": "Support on Ko-fi", + "supportPatreon": "Support on Patreon" + }, + "sections": { + "followUpdates": "Follow for Updates", + "buyMeCoffee": "Buy me a coffee", + "coffeeDescription": "If you'd like to support my work directly:", + "becomePatron": "Become a Patron", + "patronDescription": "Support ongoing development with monthly contributions:", + "wechatSupport": "WeChat Support", + "wechatDescription": "For users in China, you can support via WeChat:", + "showWechatQR": "Show WeChat QR Code", + "hideWechatQR": "Hide WeChat QR Code" + }, + "footer": "Thank you for using LoRA Manager! ❤️", + "supporters": { + "title": "Thank You To Our Supporters", + "subtitle": "Thanks to {count} supporters who made this project possible", + "specialThanks": "Special Thanks", + "allSupporters": "All Supporters", + "totalCount": "{count} supporters in total" + } + }, + "toast": { + "general": { + "cannotInteractStandalone": "Cannot interact with ComfyUI in standalone mode", + "failedWorkflowInfo": "Failed to get workflow information", + "pageInitFailed": "Failed to initialize {pageType} page. Please reload.", + "statisticsLoadFailed": "Failed to load statistics data" + }, + "loras": { + "copyOnlyForLoras": "Copy syntax is only available for LoRAs", + "noLorasSelected": "No LoRAs selected", + "missingDataForLoras": "Missing data for {count} LoRAs", + "noValidLorasToCopy": "No valid LoRAs to copy", + "sendOnlyForLoras": "Send to workflow is only available for LoRAs", + "noValidLorasToSend": "No valid LoRAs to send", + "downloadSuccessful": "LoRAs downloaded successfully", + "allDownloadSuccessful": "All {count} LoRAs downloaded successfully", + "downloadPartialSuccess": "Downloaded {completed} of {total} LoRAs", + "downloadPartialWithAccess": "Downloaded {completed} of {total} LoRAs. {accessFailures} failed due to access restrictions. Check your API key in settings or early access status.", + "pleaseSelectVersion": "Please select a version", + "versionExists": "This version already exists in your library", + "downloadCompleted": "Download completed successfully", + "downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded", + "autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}", + "autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models", + "autoOrganizeFailed": "Auto-organize failed: {error}", + "noModelsSelected": "No models selected" + }, + "recipes": { + "fetchFailed": "Failed to fetch recipes: {message}", + "reloadFailed": "Failed to reload {modelType}s: {message}", + "loadFailed": "Failed to load {modelType}s: {message}", + "refreshComplete": "Refresh complete", + "refreshFailed": "Failed to refresh recipes: {message}", + "syncComplete": "Sync complete", + "syncFailed": "Failed to sync recipes: {message}", + "updateFailed": "Failed to update recipe: {error}", + "updateError": "Error updating recipe: {message}", + "nameSaved": "Recipe \"{name}\" saved successfully", + "nameUpdated": "Recipe name updated successfully", + "tagsUpdated": "Recipe tags updated successfully", + "sourceUrlUpdated": "Source URL updated successfully", + "promptUpdated": "Prompt updated successfully", + "negativePromptUpdated": "Negative prompt updated successfully", + "promptEditorHint": "Press Enter to save, Shift+Enter for new line", + "noRecipeId": "No recipe ID available", + "sendToWorkflowFailed": "Failed to send recipe to workflow: {message}", + "copyFailed": "Error copying recipe syntax: {message}", + "createError": "Error creating recipe: {message}", + "createFailed": "Failed to create recipe: {error}", + "createMissingData": "Missing required data to create recipe", + "created": "Recipe created successfully", + "noMissingLoras": "No missing LoRAs to download", + "missingLorasInfoFailed": "Failed to get information for missing LoRAs", + "preparingForDownloadFailed": "Error preparing LoRAs for download", + "enterLoraName": "Please enter a LoRA name or syntax", + "reconnectedSuccessfully": "LoRA reconnected successfully", + "reconnectFailed": "Error reconnecting LoRA: {message}", + "noPromptToSend": "No prompt to send", + "cannotSend": "Cannot send recipe: Missing recipe ID", + "sendFailed": "Failed to send recipe to workflow", + "sendError": "Error sending recipe to workflow", + "missingCheckpointPath": "Checkpoint path not available", + "missingCheckpointInfo": "Missing checkpoint information", + "downloadCheckpointFailed": "Failed to download checkpoint: {message}", + "cannotDelete": "Cannot delete recipe: Missing recipe ID", + "deleteConfirmationError": "Error showing delete confirmation", + "deletedSuccessfully": "Recipe deleted successfully", + "deleteFailed": "Error deleting recipe: {message}", + "cannotShare": "Cannot share recipe: Missing recipe ID", + "preparingForSharing": "Preparing recipe for sharing...", + "downloadStarted": "Recipe download started", + "shareError": "Error sharing recipe: {message}", + "sharePreparationError": "Error preparing recipe for sharing", + "selectImageFirst": "Please select an image first", + "enterRecipeName": "Please enter a recipe name", + "processingError": "Processing error: {message}", + "folderBrowserError": "Error loading folder browser: {message}", + "recipeSaveFailed": "Failed to save recipe: {error}", + "recipeSaved": "Recipe saved successfully", + "importFailed": "Import failed: {message}", + "folderTreeFailed": "Failed to load folder tree", + "folderTreeError": "Error loading folder tree", + "batchImportFailed": "Failed to start batch import: {message}", + "batchImportCancelling": "Cancelling batch import...", + "batchImportCancelFailed": "Failed to cancel batch import: {message}", + "batchImportNoUrls": "Please enter at least one URL or file path", + "batchImportNoDirectory": "Please enter a directory path", + "batchImportBrowseFailed": "Failed to browse directory: {message}", + "batchImportDirectorySelected": "Directory selected: {path}", + "noRecipesSelected": "No recipes selected", + "repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})", + "repairBulkSkipped": "No repair needed for any of the {total} selected recipes", + "repairBulkFailed": "Failed to repair selected recipes: {message}", + "reimporting": "Re-importing recipe from source...", + "reimportSuccess": "Recipe re-imported successfully", + "reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})", + "reimportBulkFailed": "Failed to re-import some recipes", + "noMissingLorasInSelection": "No missing LoRAs found in selected recipes", + "noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings." + }, + "models": { + "noModelsSelected": "No models selected", + "deletedSuccessfully": "Successfully deleted {count} {type}(s)", + "deleteFailed": "Error: {error}", + "deleteFailedGeneral": "Failed to delete models", + "selectedAdditional": "Selected {count} additional {type}(s)", + "marqueeSelectionComplete": "Selected {count} {type}(s) with marquee selection", + "refreshMetadataFailed": "Failed to refresh metadata", + "nameCannotBeEmpty": "Model name cannot be empty", + "nameUpdatedSuccessfully": "Model name updated successfully", + "nameUpdateFailed": "Failed to update model name", + "baseModelUpdated": "Base model updated successfully", + "baseModelUpdateFailed": "Failed to update base model", + "baseModelNotSelected": "Please select a base model", + "bulkBaseModelUpdating": "Updating base model for {count} model(s)...", + "bulkBaseModelUpdateSuccess": "Successfully updated base model for {count} model(s)", + "bulkBaseModelUpdatePartial": "Updated {success} model(s), failed {failed} model(s)", + "bulkBaseModelUpdateFailed": "Failed to update base model for selected models", + "skipMetadataRefreshUpdating": "Updating metadata refresh flag for {count} model(s)...", + "skipMetadataRefreshSet": "Metadata refresh skipped for {count} model(s)", + "skipMetadataRefreshCleared": "Metadata refresh resumed for {count} model(s)", + "skipMetadataRefreshPartial": "Updated {success} model(s), {failed} failed", + "skipMetadataRefreshFailed": "Failed to update metadata refresh flag for selected models", + "bulkContentRatingUpdating": "Updating content rating for {count} model(s)...", + "bulkContentRatingSet": "Set content rating to {level} for {count} model(s)", + "bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed", + "bulkContentRatingFailed": "Failed to update content rating for selected models", + "bulkFavoriteUpdating": "Adding {count} model(s) to favorites...", + "bulkUnfavoriteUpdating": "Removing {count} model(s) from favorites...", + "bulkFavoritePartialAdded": "Added {success} model(s) to favorites, {failed} failed", + "bulkFavoritePartialRemoved": "Removed {success} model(s) from favorites, {failed} failed", + "bulkFavoriteFailed": "Failed to update favorite status for selected models", + "bulkUpdatesChecking": "Checking selected {type}(s) for updates...", + "bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)", + "bulkUpdatesNone": "No updates found for selected {type}(s)", + "bulkUpdatesMissing": "Selected {type}(s) are not linked to Civitai updates", + "bulkUpdatesPartialMissing": "Skipped {missing} selected {type}(s) without Civitai links", + "bulkUpdatesFailed": "Failed to check updates for selected {type}(s): {message}", + "invalidCharactersRemoved": "Invalid characters removed from filename", + "filenameCannotBeEmpty": "File name cannot be empty", + "renameFailed": "Failed to rename file: {message}", + "moveFailed": "Failed to move model(s): {message}", + "pleaseSelectRoot": "Please select a {type} root directory", + "nameTooLong": "Model name is limited to 100 characters", + "verificationAlreadyDone": "This group has already been verified", + "verificationCompleteMismatch": "Verification complete. {count} file(s) have different actual hashes.", + "verificationCompleteSuccess": "Verification complete. All files are confirmed duplicates.", + "verificationFailed": "Failed to verify hashes: {message}", + "noTagsToAdd": "No tags to add", + "bulkTagsUpdating": "Updating tags for {count} model(s)...", + "tagsAddedSuccessfully": "Successfully added {tagCount} tag(s) to {count} {type}(s)", + "tagsReplacedSuccessfully": "Successfully replaced tags for {count} {type}(s) with {tagCount} tag(s)", + "tagsAddFailed": "Failed to add tags to {count} model(s)", + "tagsReplaceFailed": "Failed to replace tags for {count} model(s)", + "bulkTagsAddFailed": "Failed to add tags to models", + "bulkTagsReplaceFailed": "Failed to replace tags for models" + }, + "search": { + "atLeastOneOption": "At least one search option must be selected" + }, + "settings": { + "loraRootsFailed": "Failed to load LoRA roots: {message}", + "checkpointRootsFailed": "Failed to load checkpoint roots: {message}", + "unetRootsFailed": "Failed to load diffusion model roots: {message}", + "embeddingRootsFailed": "Failed to load embedding roots: {message}", + "mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})", + "mappingsCleared": "Base model path mappings cleared", + "mappingSaveFailed": "Failed to save base model mappings: {message}", + "downloadTemplatesUpdated": "Download path templates updated", + "downloadTemplatesFailed": "Failed to save download path templates: {message}", + "recipesPathUpdated": "Recipes storage path updated", + "recipesPathSaveFailed": "Failed to update recipes storage path: {message}", + "settingsUpdated": "Settings updated: {setting}", + "compactModeToggled": "Compact Mode {state}", + "settingSaveFailed": "Failed to save setting: {message}", + "displayDensitySet": "Display Density set to {density}", + "libraryLoadFailed": "Failed to load libraries: {message}", + "libraryActivateFailed": "Failed to activate library: {message}", + "languageChangeFailed": "Failed to change language: {message}", + "cacheCleared": "Cache files have been cleared successfully. Cache will rebuild on next action.", + "cacheClearFailed": "Failed to clear cache: {error}", + "cacheClearError": "Error clearing cache: {message}" + }, + "filters": { + "applied": "{message}", + "cleared": "Filters cleared", + "noCustomFilterToClear": "No custom filter to clear", + "noActiveFilters": "No active filters to save" + }, + "presets": { + "created": "Preset \"{name}\" created", + "deleted": "Preset \"{name}\" deleted", + "applied": "Preset \"{name}\" applied", + "overwritten": "Preset \"{name}\" overwritten", + "restored": "Default presets restored" + }, + "error": { + "presetNameEmpty": "Preset name cannot be empty", + "presetNameTooLong": "Preset name must be {max} characters or less", + "presetNameInvalidChars": "Preset name contains invalid characters", + "presetNameExists": "A preset with this name already exists", + "maxPresetsReached": "Maximum {max} presets allowed. Delete one to add more.", + "presetNotFound": "Preset not found", + "invalidPreset": "Invalid preset data", + "deletePresetFailed": "Failed to delete preset", + "applyPresetFailed": "Failed to apply preset" + }, + "downloads": { + "imagesCompleted": "Example images {action} completed", + "imagesFailed": "Example images {action} failed", + "loadError": "Error loading downloads: {message}", + "downloadError": "Download error: {message}" + }, + "import": { + "folderTreeFailed": "Failed to load folder tree", + "folderTreeError": "Error loading folder tree", + "imagesImported": "Example images imported successfully", + "imagesPartial": "{success} image(s) imported, {failed} failed", + "importFailed": "Failed to import example images: {message}" + }, + "triggerWords": { + "loadFailed": "Could not load trained words", + "tooLong": "Trigger word should not exceed 500 words", + "tooMany": "Maximum 100 trigger words allowed", + "alreadyExists": "This trigger word already exists", + "updateSuccess": "Trigger words updated successfully", + "updateFailed": "Failed to update trigger words", + "copyFailed": "Copy failed" + }, + "virtual": { + "loadFailed": "Failed to load items", + "loadMoreFailed": "Failed to load more items", + "loadPositionFailed": "Failed to load items at this position" + }, + "bulk": { + "unableToSelectAll": "Unable to select all items" + }, + "duplicates": { + "findFailed": "Failed to find duplicates: {message}", + "noDuplicatesFound": "No duplicate {type} found", + "noItemsSelected": "No {type} selected for deletion", + "deleteError": "Error: {message}", + "deleteSuccess": "Successfully deleted {count} {type}", + "deleteFailed": "Failed to delete {type}: {message}" + }, + "controls": { + "reloadFailed": "Failed to reload {pageType}: {message}", + "refreshFailed": "Failed to {action} {pageType}: {message}", + "fetchMetadataFailed": "Failed to fetch metadata: {message}", + "clearFilterFailed": "Failed to clear custom filter: {message}" + }, + "contextMenu": { + "contentRatingSet": "Content rating set to {level}", + "contentRatingFailed": "Failed to set content rating: {message}", + "relinkSuccess": "Model successfully re-linked to Civitai", + "relinkFailed": "Error: {message}", + "fetchMetadataFirst": "Please fetch metadata from CivitAI first", + "noCivitaiInfo": "No CivitAI information available", + "missingHash": "Model hash not available" + }, + "exampleImages": { + "pathUpdated": "Example images path updated successfully", + "pathUpdateFailed": "Failed to update example images path: {message}", + "downloadInProgress": "Download already in progress", + "enterLocationFirst": "Please enter a download location first", + "downloadStarted": "Example images download started", + "downloadStartFailed": "Failed to start download: {error}", + "downloadPaused": "Download paused", + "pauseFailed": "Failed to pause download: {error}", + "downloadResumed": "Download resumed", + "resumeFailed": "Failed to resume download: {error}", + "downloadStopped": "Download cancelled", + "stopFailed": "Failed to cancel download: {error}", + "deleted": "Example image deleted", + "deleteFailed": "Failed to delete example image", + "setPreviewFailed": "Failed to set preview image" + }, + "api": { + "fetchFailed": "Failed to fetch {type}s: {message}", + "reloadFailed": "Failed to reload {type}s: {message}", + "deleteSuccess": "{type} deleted successfully", + "deleteFailed": "Failed to delete {type}: {message}", + "excludeSuccess": "{type} excluded successfully", + "excludeFailed": "Failed to exclude {type}: {message}", + "restoreSuccess": "{type} restored successfully", + "restoreFailed": "Failed to restore {type}: {message}", + "fileNameUpdated": "File name updated successfully", + "fileRenameFailed": "Failed to rename file: {error}", + "previewUpdated": "Preview updated successfully", + "previewUploadFailed": "Failed to upload preview image", + "refreshComplete": "{action} complete", + "refreshFailed": "Failed to {action} {type}s", + "metadataRefreshed": "Metadata refreshed successfully", + "metadataRefreshFailed": "Failed to refresh metadata: {message}", + "metadataUpdateComplete": "Metadata update complete", + "operationCancelled": "Operation cancelled by user", + "operationCancelledPartial": "Operation cancelled. {success} items processed.", + "metadataFetchFailed": "Failed to fetch metadata: {message}", + "bulkMetadataCompleteAll": "Successfully refreshed all {count} {type}s", + "bulkMetadataCompletePartial": "Refreshed {success} of {total} {type}s", + "bulkMetadataCompleteNone": "Failed to refresh metadata for any {type}s", + "bulkMetadataFailureDetails": "Failed refreshes:\n{failures}", + "bulkMetadataFailed": "Failed to refresh metadata: {message}", + "moveNotSupported": "Moving {type}s is not supported", + "alreadyInFolder": "{type} is already in the selected folder", + "moveInfo": "{message}", + "moveSuccess": "{type} moved successfully", + "bulkMoveNotSupported": "Moving {type}s is not supported", + "allAlreadyInFolder": "All selected {type}s are already in the target folder", + "bulkMovePartial": "Moved {successCount} {type}s, {failureCount} failed", + "bulkMoveFailures": "Failed moves:\n{failures}", + "bulkMoveSuccess": "Successfully moved {successCount} {type}s", + "exampleImagesDownloadSuccess": "Successfully downloaded example images!", + "exampleImagesDownloadFailed": "Failed to download example images: {message}", + "moveFailed": "Failed to move item: {message}", + "copiedToClipboard": "Copied to clipboard", + "downloadStarted": "Download started" + }, + "agent": { + "llmNotConfigured": "AI provider not configured. Enable it in Settings → AI Provider.", + "enrichStarted": "Enriching metadata with AI...", + "enrichComplete": "Metadata enrichment complete: {{summary}}", + "enrichFailed": "Metadata enrichment failed: {{error}}" + } + }, + "doctor": { + "kicker": "System diagnostics", + "title": "Doctor", + "buttonTitle": "Run diagnostics and common fixes", + "loading": "Checking environment...", + "footer": "Export a diagnostics bundle if the issue still persists after repair.", + "summary": { + "idle": "Run a health check for settings, cache integrity, and UI consistency.", + "ok": "No active issues were found in the current environment.", + "warning": "{count} issue(s) were found. Most can be fixed directly from this panel.", + "error": "{count} issue(s) need attention before the app is fully healthy." + }, + "status": { + "ok": "Healthy", + "warning": "Needs Attention", + "error": "Action Required" + }, + "issues": { + "civitai_api_key": { + "title": "Civitai API Key" + }, + "cache_health": { + "title": "Model Cache Health" + }, + "filename_conflicts": { + "title": "Duplicate Filename Conflicts" + }, + "ui_version": { + "title": "UI Version" + } + }, + "actions": { + "runAgain": "Run Again", + "exportBundle": "Export Bundle", + "open-settings": "Open Settings", + "open-settings-syntax-format": "Switch to Full Path Syntax", + "repair-cache": "Rebuild Cache", + "resolve-filename-conflicts": "Resolve Conflicts", + "reload-page": "Reload UI" + }, + "labels": { + "conflicts": "Conflicts", + "version": "Version" + }, + "toast": { + "loadFailed": "Failed to load diagnostics: {message}", + "repairSuccess": "Cache rebuild completed.", + "repairFailed": "Cache rebuild failed: {message}", + "exportSuccess": "Diagnostics bundle exported.", + "exportFailed": "Failed to export diagnostics bundle: {message}", + "conflictsResolved": "{count} filename conflict(s) resolved.", + "conflictsResolveFailed": "Failed to resolve filename conflicts: {message}" + } + }, + "conflictConfirm": { + "title": "Resolve Filename Conflicts", + "message": "Renaming by appending a 4-character hash to each duplicate filename.", + "note": "This operation renames files on disk. Model references in existing workflows may need updating if you use the A1111 syntax format.", + "detail": "Example: filename_v1.2filename_v1.2-ab3c", + "impact": "Will rename {count} file(s) across {groups} duplicate group(s).", + "confirm": "Rename Files", + "cancel": "Cancel" + }, + "banners": { + "versionMismatch": { + "title": "Application Update Detected", + "content": "Your browser is running an outdated version of LoRA Manager ({storedVersion}). The server has been updated to version {currentVersion}. Please refresh to ensure proper functionality.", + "refreshNow": "Refresh Now", + "refreshingIn": "Refreshing in", + "seconds": "seconds" + }, + "communitySupport": { + "title": "Keep LoRA Manager Thriving with Your Support ❤️", + "content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.", + "supportCta": "Support on Ko-fi", + "learnMore": "LM Civitai Extension Tutorial" + }, + "cacheHealth": { + "corrupted": { + "title": "Cache Corruption Detected" + }, + "degraded": { + "title": "Cache Issues Detected" + }, + "content": "{invalid} of {total} cache entries are invalid ({rate}). This may cause missing models or errors. Rebuilding the cache is recommended.", + "rebuildCache": "Rebuild Cache", + "dismiss": "Dismiss", + "rebuilding": "Rebuilding cache...", + "rebuildFailed": "Failed to rebuild cache: {error}", + "retry": "Retry" + } + } +} \ No newline at end of file diff --git a/locales/es.json b/locales/es.json index a8cfe872..71daeb5b 100644 --- a/locales/es.json +++ b/locales/es.json @@ -657,6 +657,32 @@ "proxyPassword": "Contraseña (opcional)", "proxyPasswordPlaceholder": "contraseña", "proxyPasswordHelp": "Contraseña para autenticación de proxy (si es necesario)" + }, + "aiProvider": { + "title": "Proveedor de IA", + "provider": "Proveedor", + "providerHelp": "Elija su proveedor de LLM. OpenAI y Ollama usan endpoints predefinidos. Personalizado le permite especificar cualquier endpoint compatible con OpenAI.", + "providerOptions": { + "openai": "OpenAI", + "ollama": "Ollama (local)", + "deepseek": "DeepSeek", + "groq": "Groq", + "openrouter": "OpenRouter", + "opencode-go": "OpenCode Go", + "custom": "Personalizado (compatible con OpenAI)" + }, + "apiBase": "URL base de la API", + "apiBaseHelp": "La URL base para la API LLM (p.ej. https://api.openai.com/v1). Déjelo vacío para usar el valor predeterminado del proveedor.", + "apiBasePlaceholder": "https://api.openai.com/v1", + "apiKey": "Clave de API", + "apiKeyHelp": "Su clave de API del proveedor LLM. Se almacena localmente y nunca se envía a ningún servidor excepto a su proveedor LLM elegido.", + "apiKeyPlaceholder": "sk-...", + "apiKeyNotSet": "No configurada", + "apiKeyConfigured": "Configurada", + "apiKeySet": "Configurar", + "model": "Modelo", + "modelHelp": "El nombre del modelo a usar (p.ej. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Consulte a su proveedor para ver los modelos disponibles.", + "modelPlaceholder": "Seleccionar un modelo..." } }, "loras": { @@ -754,7 +780,8 @@ "completed": "Completado: {success} movidos, {skipped} omitidos, {failures} fallidos", "complete": "Auto-organización completada", "error": "Error: {error}" - } + }, + "enrichHfAgent": "Enriquecer metadatos HF (IA)" }, "contextMenu": { "refreshMetadata": "Actualizar datos de Civitai", @@ -778,7 +805,8 @@ "shareRecipe": "Compartir receta", "viewAllLoras": "Ver todos los LoRAs", "downloadMissingLoras": "Descargar LoRAs faltantes", - "deleteRecipe": "Eliminar receta" + "deleteRecipe": "Eliminar receta", + "enrichHfAgent": "Enriquecer metadatos HF (IA)" } }, "recipes": { @@ -2081,6 +2109,12 @@ "moveFailed": "Failed to move item: {message}", "copiedToClipboard": "Copiado al portapapeles", "downloadStarted": "Descarga iniciada" + }, + "agent": { + "llmNotConfigured": "Proveedor de IA no configurado. Actívelo en Configuración → Proveedor de IA.", + "enrichStarted": "Enriqueciendo metadatos con IA...", + "enrichComplete": "Enriquecimiento de metadatos completado: {{summary}}", + "enrichFailed": "Enriquecimiento de metadatos fallido: {{error}}" } }, "doctor": { diff --git a/locales/fr.json b/locales/fr.json index 87c0f0a3..f250c4d3 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -657,6 +657,32 @@ "proxyPassword": "Mot de passe (optionnel)", "proxyPasswordPlaceholder": "mot_de_passe", "proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)" + }, + "aiProvider": { + "title": "Fournisseur d'IA", + "provider": "Fournisseur", + "providerHelp": "Choisissez votre fournisseur LLM. OpenAI et Ollama utilisent des endpoints prédéfinis. Personnalisé vous permet de spécifier n'importe quel endpoint compatible OpenAI.", + "providerOptions": { + "openai": "OpenAI", + "ollama": "Ollama (local)", + "deepseek": "DeepSeek", + "groq": "Groq", + "openrouter": "OpenRouter", + "opencode-go": "OpenCode Go", + "custom": "Personnalisé (compatible OpenAI)" + }, + "apiBase": "URL de base de l'API", + "apiBaseHelp": "L'URL de base pour l'API LLM (ex. https://api.openai.com/v1). Laissez vide pour utiliser le fournisseur par défaut.", + "apiBasePlaceholder": "https://api.openai.com/v1", + "apiKey": "Clé API", + "apiKeyHelp": "Votre clé API du fournisseur LLM. Stockée localement, jamais envoyée à un serveur autre que votre fournisseur LLM choisi.", + "apiKeyPlaceholder": "sk-...", + "apiKeyNotSet": "Non définie", + "apiKeyConfigured": "Configurée", + "apiKeySet": "Configurer", + "model": "Modèle", + "modelHelp": "Le nom du modèle à utiliser (ex. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Consultez votre fournisseur pour les modèles disponibles.", + "modelPlaceholder": "Sélectionner un modèle..." } }, "loras": { @@ -754,7 +780,8 @@ "completed": "Terminé : {success} déplacés, {skipped} ignorés, {failures} échecs", "complete": "Auto-organisation terminée", "error": "Erreur : {error}" - } + }, + "enrichHfAgent": "Enrichir les métadonnées HF (IA)" }, "contextMenu": { "refreshMetadata": "Actualiser les données Civitai", @@ -778,7 +805,8 @@ "shareRecipe": "Partager la recipe", "viewAllLoras": "Voir tous les LoRAs", "downloadMissingLoras": "Télécharger les LoRAs manquants", - "deleteRecipe": "Supprimer la recipe" + "deleteRecipe": "Supprimer la recipe", + "enrichHfAgent": "Enrichir les métadonnées HF (IA)" } }, "recipes": { @@ -2081,6 +2109,12 @@ "moveFailed": "Failed to move item: {message}", "copiedToClipboard": "Copié dans le presse-papiers", "downloadStarted": "Téléchargement démarré" + }, + "agent": { + "llmNotConfigured": "Fournisseur d'IA non configuré. Activez-le dans Paramètres → Fournisseur d'IA.", + "enrichStarted": "Enrichissement des métadonnées par IA...", + "enrichComplete": "Enrichissement des métadonnées terminé : {{summary}}", + "enrichFailed": "Échec de l'enrichissement des métadonnées : {{error}}" } }, "doctor": { diff --git a/locales/he.json b/locales/he.json index 2126807d..bbff7d9f 100644 --- a/locales/he.json +++ b/locales/he.json @@ -657,6 +657,32 @@ "proxyPassword": "סיסמה (אופציונלי)", "proxyPasswordPlaceholder": "password", "proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)" + }, + "aiProvider": { + "title": "ספק AI", + "provider": "ספק", + "providerHelp": "בחר את ספק ה-LLM שלך. OpenAI ו-Ollama משתמשים בנקודות קצה מוגדרות מראש. מותאם אישית מאפשר לך לציין כל נקודת קצה תואמת OpenAI.", + "providerOptions": { + "openai": "OpenAI", + "ollama": "Ollama (מקומי)", + "deepseek": "DeepSeek", + "groq": "Groq", + "openrouter": "OpenRouter", + "opencode-go": "OpenCode Go", + "custom": "מותאם אישית (תואם OpenAI)" + }, + "apiBase": "כתובת בסיס API", + "apiBaseHelp": "כתובת ה-URL הבסיסית ל-API של LLM (לדוגמה https://api.openai.com/v1). השאר ריק לשימוש בברירת המחדל של הספק.", + "apiBasePlaceholder": "https://api.openai.com/v1", + "apiKey": "מפתח API", + "apiKeyHelp": "מפתח ה-API של ספק ה-LLM שלך. נשמר מקומית, לעולם לא נשלח לשרת כלשהו מלבד ספק ה-LLM שבחרת.", + "apiKeyPlaceholder": "sk-...", + "apiKeyNotSet": "לא הוגדר", + "apiKeyConfigured": "הוגדר", + "apiKeySet": "הגדר", + "model": "מודל", + "modelHelp": "שם המודל לשימוש (לדוגמה deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). בדוק אצל הספק שלך אילו מודלים זמינים.", + "modelPlaceholder": "בחר מודל..." } }, "loras": { @@ -754,7 +780,8 @@ "completed": "הושלם: {success} הועברו, {skipped} דולגו, {failures} נכשלו", "complete": "ארגון אוטומטי הושלם", "error": "שגיאה: {error}" - } + }, + "enrichHfAgent": "העשרת HF מטא-דאטה (AI)" }, "contextMenu": { "refreshMetadata": "רענן נתוני Civitai", @@ -778,7 +805,8 @@ "shareRecipe": "שתף מתכון", "viewAllLoras": "הצג את כל ה-LoRAs", "downloadMissingLoras": "הורד LoRAs חסרים", - "deleteRecipe": "מחק מתכון" + "deleteRecipe": "מחק מתכון", + "enrichHfAgent": "העשרת HF מטא-דאטה (AI)" } }, "recipes": { @@ -2081,6 +2109,12 @@ "moveFailed": "Failed to move item: {message}", "copiedToClipboard": "הועתק ללוח", "downloadStarted": "ההורדה החלה" + }, + "agent": { + "llmNotConfigured": "ספק AI לא הוגדר. הפעל אותו בהגדרות → ספק AI.", + "enrichStarted": "מעשיר מטא-דאטה באמצעות AI...", + "enrichComplete": "העשרת מטא-דאטה הושלמה: {{summary}}", + "enrichFailed": "העשרת מטא-דאטה נכשלה: {{error}}" } }, "doctor": { diff --git a/locales/ja.json b/locales/ja.json index 0c942159..bf6c5919 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -657,6 +657,32 @@ "proxyPassword": "パスワード(任意)", "proxyPasswordPlaceholder": "パスワード", "proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)" + }, + "aiProvider": { + "title": "AIプロバイダー", + "provider": "プロバイダー", + "providerHelp": "LLMプロバイダーを選択してください。OpenAIとOllamaはプリセットのAPIエンドポイントを使用します。カスタムでは任意のOpenAI互換エンドポイントを指定できます。", + "providerOptions": { + "openai": "OpenAI", + "ollama": "Ollama(ローカル)", + "deepseek": "DeepSeek", + "groq": "Groq", + "openrouter": "OpenRouter", + "opencode-go": "OpenCode Go", + "custom": "カスタム(OpenAI 互換)" + }, + "apiBase": "APIベースURL", + "apiBaseHelp": "LLM APIのベースURL(例:https://api.openai.com/v1)。空の場合はプロバイダーのデフォルトが使用されます。", + "apiBasePlaceholder": "https://api.openai.com/v1", + "apiKey": "APIキー", + "apiKeyHelp": "LLMプロバイダーのAPIキー。ローカルに保存され、選択したLLMプロバイダー以外のサーバーに送信されることはありません。", + "apiKeyPlaceholder": "sk-...", + "apiKeyNotSet": "未設定", + "apiKeyConfigured": "設定済み", + "apiKeySet": "設定", + "model": "モデル", + "modelHelp": "使用するモデル名(例:deepseek-v4-flash, gemini-2.5-flash, gemma4:12b)。プロバイダーで利用可能なモデルをご確認ください。", + "modelPlaceholder": "モデルを選択..." } }, "loras": { @@ -754,7 +780,8 @@ "completed": "完了:{success} 移動、{skipped} スキップ、{failures} 失敗", "complete": "自動整理が完了しました", "error": "エラー:{error}" - } + }, + "enrichHfAgent": "HF メタデータをAIで補完" }, "contextMenu": { "refreshMetadata": "Civitaiデータを更新", @@ -778,7 +805,8 @@ "shareRecipe": "レシピを共有", "viewAllLoras": "すべてのLoRAを表示", "downloadMissingLoras": "不足しているLoRAをダウンロード", - "deleteRecipe": "レシピを削除" + "deleteRecipe": "レシピを削除", + "enrichHfAgent": "HF メタデータをAIで補完" } }, "recipes": { @@ -2081,6 +2109,12 @@ "moveFailed": "Failed to move item: {message}", "copiedToClipboard": "クリップボードにコピーしました", "downloadStarted": "ダウンロードを開始しました" + }, + "agent": { + "llmNotConfigured": "AIプロバイダーが設定されていません。設定 → AIプロバイダーで有効にしてください。", + "enrichStarted": "AIでメタデータを補完中...", + "enrichComplete": "メタデータの補完が完了しました:{{summary}}", + "enrichFailed": "メタデータの補完に失敗しました:{{error}}" } }, "doctor": { diff --git a/locales/ko.json b/locales/ko.json index b1361f25..9680e4a8 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -657,6 +657,32 @@ "proxyPassword": "비밀번호 (선택사항)", "proxyPasswordPlaceholder": "password", "proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)" + }, + "aiProvider": { + "title": "AI 제공자", + "provider": "제공자", + "providerHelp": "LLM 제공자를 선택하세요. OpenAI와 Ollama는 사전 설정된 API 엔드포인트를 사용합니다. 사용자 정의를 선택하면 모든 OpenAI 호환 엔드포인트를 지정할 수 있습니다.", + "providerOptions": { + "openai": "OpenAI", + "ollama": "Ollama (로컬)", + "deepseek": "DeepSeek", + "groq": "Groq", + "openrouter": "OpenRouter", + "opencode-go": "OpenCode Go", + "custom": "사용자 정의 (OpenAI 호환)" + }, + "apiBase": "API 기본 URL", + "apiBaseHelp": "LLM API의 기본 URL입니다 (예: https://api.openai.com/v1). 비워두면 제공자 기본값이 사용됩니다.", + "apiBasePlaceholder": "https://api.openai.com/v1", + "apiKey": "API 키", + "apiKeyHelp": "LLM 제공자의 API 키입니다. 로컬에 저장되며 선택한 LLM 제공자 외의 서버로 전송되지 않습니다.", + "apiKeyPlaceholder": "sk-...", + "apiKeyNotSet": "설정되지 않음", + "apiKeyConfigured": "설정됨", + "apiKeySet": "설정", + "model": "모델", + "modelHelp": "사용할 모델 이름 (예: deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). 제공자에서 사용 가능한 모델을 확인하세요.", + "modelPlaceholder": "모델 선택..." } }, "loras": { @@ -754,7 +780,8 @@ "completed": "완료: {success}개 이동, {skipped}개 건너뜀, {failures}개 실패", "complete": "자동 정리 완료", "error": "오류: {error}" - } + }, + "enrichHfAgent": "HF AI로 메타데이터 보강" }, "contextMenu": { "refreshMetadata": "Civitai 데이터 새로고침", @@ -778,7 +805,8 @@ "shareRecipe": "레시피 공유", "viewAllLoras": "모든 LoRA 보기", "downloadMissingLoras": "누락된 LoRA 다운로드", - "deleteRecipe": "레시피 삭제" + "deleteRecipe": "레시피 삭제", + "enrichHfAgent": "HF AI로 메타데이터 보강" } }, "recipes": { @@ -2081,6 +2109,12 @@ "moveFailed": "Failed to move item: {message}", "copiedToClipboard": "클립보드에 복사됨", "downloadStarted": "다운로드 시작됨" + }, + "agent": { + "llmNotConfigured": "AI 제공자가 설정되지 않았습니다. 설정 → AI 제공자에서 활성화하세요.", + "enrichStarted": "AI로 메타데이터 보강 중...", + "enrichComplete": "메타데이터 보강 완료: {{summary}}", + "enrichFailed": "메타데이터 보강 실패: {{error}}" } }, "doctor": { diff --git a/locales/ru.json b/locales/ru.json index 4f53b126..60a1f990 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -657,6 +657,32 @@ "proxyPassword": "Пароль (необязательно)", "proxyPasswordPlaceholder": "пароль", "proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)" + }, + "aiProvider": { + "title": "Поставщик ИИ", + "provider": "Поставщик", + "providerHelp": "Выберите поставщика LLM. OpenAI и Ollama используют предустановленные API-эндпоинты. Пользовательский позволяет указать любой совместимый с OpenAI эндпоинт.", + "providerOptions": { + "openai": "OpenAI", + "ollama": "Ollama (локальный)", + "deepseek": "DeepSeek", + "groq": "Groq", + "openrouter": "OpenRouter", + "opencode-go": "OpenCode Go", + "custom": "Пользовательский (совместимый с OpenAI)" + }, + "apiBase": "Базовый URL API", + "apiBaseHelp": "Базовый URL для LLM API (например, https://api.openai.com/v1). Оставьте пустым, чтобы использовать значение по умолчанию.", + "apiBasePlaceholder": "https://api.openai.com/v1", + "apiKey": "API-ключ", + "apiKeyHelp": "Ваш API-ключ поставщика LLM. Хранится локально и никогда не отправляется на другие серверы, кроме выбранного поставщика LLM.", + "apiKeyPlaceholder": "sk-...", + "apiKeyNotSet": "Не задан", + "apiKeyConfigured": "Настроен", + "apiKeySet": "Настроить", + "model": "Модель", + "modelHelp": "Имя модели для использования (например, deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Проверьте доступные модели у вашего поставщика.", + "modelPlaceholder": "Выберите модель..." } }, "loras": { @@ -754,7 +780,8 @@ "completed": "Завершено: {success} перемещено, {skipped} пропущено, {failures} не удалось", "complete": "Автоматическая организация завершена", "error": "Ошибка: {error}" - } + }, + "enrichHfAgent": "Обогатить HF метаданные (ИИ)" }, "contextMenu": { "refreshMetadata": "Обновить данные Civitai", @@ -778,7 +805,8 @@ "shareRecipe": "Поделиться рецептом", "viewAllLoras": "Посмотреть все LoRAs", "downloadMissingLoras": "Загрузить отсутствующие LoRAs", - "deleteRecipe": "Удалить рецепт" + "deleteRecipe": "Удалить рецепт", + "enrichHfAgent": "Обогатить HF метаданные (ИИ)" } }, "recipes": { @@ -2081,6 +2109,12 @@ "moveFailed": "Failed to move item: {message}", "copiedToClipboard": "Скопировано в буфер обмена", "downloadStarted": "Загрузка начата" + }, + "agent": { + "llmNotConfigured": "Поставщик ИИ не настроен. Включите его в Настройки → Поставщик ИИ.", + "enrichStarted": "Обогащение метаданных с помощью ИИ...", + "enrichComplete": "Обогащение метаданных завершено: {{summary}}", + "enrichFailed": "Ошибка обогащения метаданных: {{error}}" } }, "doctor": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index dd383d20..98a56d8d 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -657,6 +657,32 @@ "proxyPassword": "密码 (可选)", "proxyPasswordPlaceholder": "密码", "proxyPasswordHelp": "代理认证的密码 (如果需要)" + }, + "aiProvider": { + "title": "AI 提供商", + "provider": "提供商", + "providerHelp": "选择您的 LLM 提供商。OpenAI 和 Ollama 使用预设的 API 端点。自定义允许您指定任何兼容 OpenAI 的端点。", + "providerOptions": { + "openai": "OpenAI", + "ollama": "Ollama(本地)", + "deepseek": "DeepSeek", + "groq": "Groq", + "openrouter": "OpenRouter", + "opencode-go": "OpenCode Go", + "custom": "自定义(OpenAI 兼容)" + }, + "apiBase": "API 基础地址", + "apiBaseHelp": "LLM API 的基础地址。选择预设或输入自定义地址,下拉框显示所有支持的提供商预设。", + "apiBasePlaceholder": "https://api.openai.com/v1", + "apiKey": "API 密钥", + "apiKeyHelp": "LLM 提供商的 API 密钥。本地存储,除您选择的 LLM 提供商外不会发送到任何服务器。", + "apiKeyPlaceholder": "sk-...", + "apiKeyNotSet": "未设置", + "apiKeyConfigured": "已配置", + "apiKeySet": "设置", + "model": "模型", + "modelHelp": "要使用的模型。从下拉框选择(从提供商获取)或输入自定义模型名称。", + "modelPlaceholder": "选择一个模型..." } }, "loras": { @@ -754,7 +780,8 @@ "completed": "完成:已移动 {success} 个,跳过 {skipped} 个,失败 {failures} 个", "complete": "自动整理已完成", "error": "错误:{error}" - } + }, + "enrichHfAgent": "AI HF 元数据增强" }, "contextMenu": { "refreshMetadata": "刷新 Civitai 数据", @@ -778,7 +805,8 @@ "shareRecipe": "分享配方", "viewAllLoras": "查看所有 LoRA", "downloadMissingLoras": "下载缺失的 LoRA", - "deleteRecipe": "删除配方" + "deleteRecipe": "删除配方", + "enrichHfAgent": "AI HF 元数据增强" } }, "recipes": { @@ -2081,6 +2109,12 @@ "moveFailed": "Failed to move item: {message}", "copiedToClipboard": "已复制到剪贴板", "downloadStarted": "下载已开始" + }, + "agent": { + "llmNotConfigured": "AI 提供商未配置。请在 设置 → AI 提供商 中进行配置。", + "enrichStarted": "正在使用 AI 增强元数据...", + "enrichComplete": "元数据增强完成:{{summary}}", + "enrichFailed": "元数据增强失败:{{error}}" } }, "doctor": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 62b7f6d3..7478a628 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -657,6 +657,32 @@ "proxyPassword": "密碼(選填)", "proxyPasswordPlaceholder": "password", "proxyPasswordHelp": "代理驗證所需的密碼(如有需要)" + }, + "aiProvider": { + "title": "AI 提供者", + "provider": "提供者", + "providerHelp": "選擇您的 LLM 提供者。OpenAI 和 Ollama 使用預設 API 端點。自訂允許您指定任何相容 OpenAI 的端點。", + "providerOptions": { + "openai": "OpenAI", + "ollama": "Ollama(本地)", + "deepseek": "DeepSeek", + "groq": "Groq", + "openrouter": "OpenRouter", + "opencode-go": "OpenCode Go", + "custom": "自訂(OpenAI 相容)" + }, + "apiBase": "API 基礎網址", + "apiBaseHelp": "LLM API 的基礎網址。選擇預設或輸入自訂網址,下拉選單顯示所有支援的提供者預設。", + "apiBasePlaceholder": "https://api.openai.com/v1", + "apiKey": "API 金鑰", + "apiKeyHelp": "LLM 提供者的 API 金鑰。儲存在本地,除您選擇的 LLM 提供者外不會傳送到任何伺服器。", + "apiKeyPlaceholder": "[TODO: Translate] sk-...", + "apiKeyNotSet": "未設定", + "apiKeyConfigured": "已設定", + "apiKeySet": "設定", + "model": "模型", + "modelHelp": "要使用的模型。從下拉選單選擇(從提供者取得)或輸入自訂模型名稱。", + "modelPlaceholder": "選擇一個模型..." } }, "loras": { @@ -754,7 +780,8 @@ "completed": "完成:已移動 {success},已略過 {skipped},失敗 {failures}", "complete": "自動整理完成", "error": "錯誤:{error}" - } + }, + "enrichHfAgent": "AI HF 中繼資料增強" }, "contextMenu": { "refreshMetadata": "刷新 Civitai 資料", @@ -778,7 +805,8 @@ "shareRecipe": "分享配方", "viewAllLoras": "檢視全部 LoRA", "downloadMissingLoras": "下載缺少的 LoRA", - "deleteRecipe": "刪除配方" + "deleteRecipe": "刪除配方", + "enrichHfAgent": "AI HF 中繼資料增強" } }, "recipes": { @@ -2081,6 +2109,12 @@ "moveFailed": "Failed to move item: {message}", "copiedToClipboard": "已複製到剪貼簿", "downloadStarted": "下載已開始" + }, + "agent": { + "llmNotConfigured": "AI 提供者尚未設定。請在 設定 → AI 提供者 中進行設定。", + "enrichStarted": "正在使用 AI 增強中繼資料...", + "enrichComplete": "中繼資料增強完成:{{summary}}", + "enrichFailed": "中繼資料增強失敗:{{error}}" } }, "doctor": { diff --git a/py/config.py b/py/config.py index 8f07ed9c..0931b6fa 100644 --- a/py/config.py +++ b/py/config.py @@ -8,6 +8,8 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple import logging import json import urllib.parse +import sys as _sys +import types as _types import time from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths @@ -175,8 +177,7 @@ class Config: # Load extra folder paths from active library settings before symlink scan # so both primary and extra paths are discovered in a single pass. - if not standalone_mode: - self._load_extra_paths_from_settings() + self._load_extra_paths_from_settings() # Scan symbolic links during initialization self._initialize_symlink_mappings() @@ -191,7 +192,7 @@ class Config: Called during ``Config.__init__`` before the symlink scan so both primary and extra paths are discovered in a single pass. Mirrors the extra-path portion of ``_apply_library_paths`` without replacing the primary roots - that were already resolved from ComfyUI's ``folder_paths``. + that were already resolved via ``folder_paths.get_folder_paths``. """ try: from .services.settings_manager import get_settings_manager @@ -1380,4 +1381,20 @@ class Config: # Global config instance -config = Config() +# NOTE: Guard against re-import. When ServiceRegistry.get_lora_scanner() triggers +# a fresh import of lora_scanner → config, we must NOT re-execute Config.__init__() +# (which re-scans all roots, re-registers libraries, etc.). +# +# Strategy: store the config instance in a dedicated sentinel module +# ('_lm_config_cache') that is NEVER removed from sys.modules (its key does +# NOT start with 'py.'), so it survives re-imports of py.* modules. +_CONFIG_SENTINEL = "_lm_config_cache" +if _CONFIG_SENTINEL in _sys.modules: + # Re-import: reuse the existing singleton from the sentinel. + config: Config = _sys.modules[_CONFIG_SENTINEL].config # type: ignore[valid-type] +else: + config: Config = Config() + # Register the sentinel so re-imports of py.config find us. + _sentinel_mod = _types.ModuleType(_CONFIG_SENTINEL) + _sentinel_mod.config = config + _sys.modules[_CONFIG_SENTINEL] = _sentinel_mod diff --git a/py/lora_manager.py b/py/lora_manager.py index a8579ddb..f304bc77 100644 --- a/py/lora_manager.py +++ b/py/lora_manager.py @@ -208,6 +208,10 @@ class LoraManager: # Initialize WebSocket manager await ServiceRegistry.get_websocket_manager() + # Preload LLM model catalog (background task, non-blocking) + from .services.llm_service import LLMService + await LLMService.get_instance() + # Initialize scanners in background lora_scanner = await ServiceRegistry.get_lora_scanner() checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner() @@ -445,5 +449,12 @@ class LoraManager: scanner.cancel_task() logger.debug("LoRA Manager: Cancelled %s", name) + # Close shared aiohttp sessions to avoid "Unclosed client session" warnings + try: + from py.routes.handlers.hf_handlers import close_hf_api_session + await close_hf_api_session() + except Exception as exc: + logger.debug("Error closing HF API session: %s", exc) + except Exception as e: logger.error(f"Error during cleanup: {e}", exc_info=True) diff --git a/py/metadata_ops/__init__.py b/py/metadata_ops/__init__.py new file mode 100644 index 00000000..d5b33efe --- /dev/null +++ b/py/metadata_ops/__init__.py @@ -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 diff --git a/py/metadata_ops/__main__.py b/py/metadata_ops/__main__.py new file mode 100644 index 00000000..4a9857a8 --- /dev/null +++ b/py/metadata_ops/__main__.py @@ -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 + python -m py.metadata_ops metadata update --json '{...}' + python -m py.metadata_ops preview download --url + python -m py.metadata_ops cache refresh +""" + +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() diff --git a/py/routes/handlers/agent_handlers.py b/py/routes/handlers/agent_handlers.py new file mode 100644 index 00000000..ce1ff519 --- /dev/null +++ b/py/routes/handlers/agent_handlers.py @@ -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, + ) diff --git a/py/routes/handlers/hf_handlers.py b/py/routes/handlers/hf_handlers.py index e392ccbc..77fe0dc5 100644 --- a/py/routes/handlers/hf_handlers.py +++ b/py/routes/handlers/hf_handlers.py @@ -49,6 +49,14 @@ async def _get_hf_api_session() -> aiohttp.ClientSession: 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``). diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 0ab63ca7..b62af8b8 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -38,6 +38,12 @@ from ...services.settings_manager import get_settings_manager from ...services.websocket_manager import ws_manager from ...services.downloader import get_downloader from ...services.errors import ResourceNotFoundError +from ...services.llm_service import ( + PROVIDER_PRESETS, + fetch_ollama_models, + get_all_provider_models, + get_provider_model_ids, +) from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus from ...utils.models import BaseModelMetadata from ...utils.constants import ( @@ -49,6 +55,7 @@ from ...utils.constants import ( VALID_LORA_TYPES, ) from .hf_handlers import HfHandler +from .agent_handlers import AgentHandler from ...utils.civitai_utils import rewrite_preview_url from ...utils.example_images_paths import ( find_non_compliant_items_in_example_images_root, @@ -1399,8 +1406,9 @@ class SettingsHandler: "libraries", "active_library", # 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", + "llm_api_key", } ) @@ -1458,6 +1466,8 @@ class SettingsHandler: # Sensitive fields: only expose a boolean indicating whether set raw_key = self._settings.get("civitai_api_key") response_data["civitai_api_key_set"] = bool(raw_key) + raw_llm_key = self._settings.get("llm_api_key") + response_data["llm_api_key_set"] = bool(raw_llm_key) settings_file = getattr(self._settings, "settings_file", None) if settings_file: response_data["settings_file"] = settings_file @@ -1562,6 +1572,42 @@ class SettingsHandler: logger.error("Error updating settings: %s", exc, exc_info=True) return web.Response(status=500, text=str(exc)) + async def get_llm_models(self, request: web.Request) -> web.Response: + """Return the model list for a provider. + + For ``ollama`` the list is fetched live from the local Ollama API + (only models actually pulled locally are shown). For all other + providers the opencode model catalog is used. + + Query parameters: + provider (required): Internal provider id (``openai``, ``ollama``, etc.). + + Returns: + ``{"success": true, "models": ["gpt-4o", ...]}``. + """ + provider_id = request.query.get("provider", "").strip() + if not provider_id: + return web.json_response( + {"success": False, "error": "provider query parameter is required", "models": []}, + status=400, + ) + + try: + if provider_id == "ollama": + api_base = request.query.get("api_base", "").strip() or self._settings.get("llm_api_base", "") + if not api_base: + api_base = "http://localhost:11434/v1" + models = await fetch_ollama_models(api_base) + else: + models = await get_provider_model_ids(provider_id) + return web.json_response({"success": True, "models": models}) + except Exception as exc: + logger.warning("get_llm_models failed for %s: %s", provider_id, exc) + return web.json_response( + {"success": False, "error": str(exc), "models": []}, + status=500, + ) + def _validate_example_images_path(self, folder_path: str) -> str | None: if not os.path.exists(folder_path): return f"Path does not exist: {folder_path}" @@ -1584,6 +1630,20 @@ class SettingsHandler: def _is_dedicated_example_images_folder(self, folder_path: str) -> bool: return is_valid_example_images_root(folder_path) + async def get_provider_models(self, request: web.Request) -> web.Response: + """Return the model catalog for all preset providers. + + This endpoint is called asynchronously by the settings UI so that + page rendering never blocks on the remote model catalog fetch. + """ + catalog_provider_ids = [p for p in PROVIDER_PRESETS if p != "custom"] + try: + provider_models = await get_all_provider_models(catalog_provider_ids) + return web.json_response({"success": True, "models": provider_models}) + except Exception as exc: + logger.warning("Failed to fetch provider models: %s", exc) + return web.json_response({"success": False, "models": {}, "error": str(exc)}) + class UsageStatsHandler: def __init__(self, usage_stats_factory: UsageStatsFactory = UsageStats) -> None: @@ -3317,6 +3377,7 @@ class MiscHandlerSet: example_workflows: ExampleWorkflowsHandler, base_model: BaseModelHandlerSet, hf_handler: HfHandler | None = None, + agent_handler: AgentHandler | None = None, ) -> None: self.health = health self.settings = settings @@ -3336,6 +3397,7 @@ class MiscHandlerSet: self.example_workflows = example_workflows self.base_model = base_model self.hf_handler = hf_handler + self.agent_handler = agent_handler def to_route_mapping( self, @@ -3351,6 +3413,8 @@ class MiscHandlerSet: "get_priority_tags": self.settings.get_priority_tags, "get_settings_libraries": self.settings.get_libraries, "activate_library": self.settings.activate_library, + "get_llm_models": self.settings.get_llm_models, + "get_provider_models": self.settings.get_provider_models, "update_usage_stats": self.usage_stats.update_usage_stats, "get_usage_stats": self.usage_stats.get_usage_stats, "update_lora_code": self.lora_code.update_lora_code, @@ -3384,6 +3448,10 @@ class MiscHandlerSet: # Hugging Face handlers "get_hf_repo_files": self.hf_handler.get_hf_repo_files, "download_hf_model": self.hf_handler.download_hf_model, + # Agent skill handlers + "get_agent_skills": self.agent_handler.get_agent_skills, + "execute_agent_skill": self.agent_handler.execute_agent_skill, + "cancel_agent_skill": self.agent_handler.cancel_agent_skill, # Base model handlers "get_base_models": self.base_model.get_base_models, "refresh_base_models": self.base_model.refresh_base_models, diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 54f4f45f..ea9dfd8b 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -154,6 +154,14 @@ class ModelPageView: ) self._template_env._i18n_filter_added = True # type: ignore[attr-defined] + from ...services.llm_service import PROVIDER_PRESETS + + # Provider presets are embedded directly (local, no await needed). + # Provider model catalogs are fetched asynchronously by the + # frontend via GET /api/lm/llm/provider-models so page rendering + # never blocks on the remote model catalog (which can take up to + # 30s on cold cache). + template_context = { "is_initializing": is_initializing, "settings": self._settings, @@ -161,6 +169,8 @@ class ModelPageView: "folders": [], "t": self._server_i18n.get_translation, "version": self._get_app_version(), + "provider_presets_json": json.dumps(PROVIDER_PRESETS), + "provider_models_json": "{}", } if not is_initializing: diff --git a/py/routes/misc_route_registrar.py b/py/routes/misc_route_registrar.py index a44c8d3f..4c29bb33 100644 --- a/py/routes/misc_route_registrar.py +++ b/py/routes/misc_route_registrar.py @@ -22,6 +22,8 @@ class RouteDefinition: MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("GET", "/api/lm/settings", "get_settings"), RouteDefinition("POST", "/api/lm/settings", "update_settings"), + RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"), + RouteDefinition("GET", "/api/lm/llm/provider-models", "get_provider_models"), RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"), RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"), RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"), @@ -101,6 +103,16 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition( "POST", "/api/lm/download-hf-model", "download_hf_model" ), + # 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" + ), ) diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py index e45adf55..e0e77013 100644 --- a/py/routes/misc_routes.py +++ b/py/routes/misc_routes.py @@ -40,6 +40,7 @@ from .handlers.misc_handlers import ( ) from .handlers.base_model_handlers import BaseModelHandlerSet from .handlers.hf_handlers import HfHandler +from .handlers.agent_handlers import AgentHandler from .misc_route_registrar import MiscRouteRegistrar logger = logging.getLogger(__name__) @@ -138,6 +139,7 @@ class MiscRoutes: example_workflows = ExampleWorkflowsHandler() base_model = BaseModelHandlerSet() hf_handler = HfHandler() + agent_handler = AgentHandler() return self._handler_set_factory( health=health, @@ -158,6 +160,7 @@ class MiscRoutes: example_workflows=example_workflows, base_model=base_model, hf_handler=hf_handler, + agent_handler=agent_handler, ) diff --git a/py/services/agent/__init__.py b/py/services/agent/__init__.py new file mode 100644 index 00000000..6c6a7bdb --- /dev/null +++ b/py/services/agent/__init__.py @@ -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", +] diff --git a/py/services/agent/agent_service.py b/py/services/agent/agent_service.py new file mode 100644 index 00000000..ee8dfbbf --- /dev/null +++ b/py/services/agent/agent_service.py @@ -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"] = "" + + # 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) diff --git a/py/services/agent/post_processor.py b/py/services/agent/post_processor.py new file mode 100644 index 00000000..a13c8975 --- /dev/null +++ b/py/services/agent/post_processor.py @@ -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 `![alt](url)` 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 `` 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 "" diff --git a/py/services/agent/skill_definition.py b/py/services/agent/skill_definition.py new file mode 100644 index 00000000..3d6960d7 --- /dev/null +++ b/py/services/agent/skill_definition.py @@ -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 diff --git a/py/services/agent/skill_registry.py b/py/services/agent/skill_registry.py new file mode 100644 index 00000000..d3f2c4ce --- /dev/null +++ b/py/services/agent/skill_registry.py @@ -0,0 +1,210 @@ +"""Discovery and loading of prompt-based skills. + +Skills live in ``py/services/agent/skills//`` 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 diff --git a/py/services/agent/skills/enrich_hf_metadata/prompt.md b/py/services/agent/skills/enrich_hf_metadata/prompt.md new file mode 100644 index 00000000..61118a54 --- /dev/null +++ b/py/services/agent/skills/enrich_hf_metadata/prompt.md @@ -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 `![alt](url)` 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** (``) or section headings whose text matches words from the filename. +3. **Search for HTML headings** (`

`, `

`, ``) 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": "", + "trigger_words": ["", ""], + "short_description": "<1-2 sentence summary>", + "tags": ["", ""], + "recommended_width": 768, + "recommended_height": 1024, + "preview_url": "", + "notes": "", + "usage_tips": "", + "confidence": "" +} +``` + +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 diff --git a/py/services/agent/skills/enrich_hf_metadata/readme_processor.py b/py/services/agent/skills/enrich_hf_metadata/readme_processor.py new file mode 100644 index 00000000..b5e8d829 --- /dev/null +++ b/py/services/agent/skills/enrich_hf_metadata/readme_processor.py @@ -0,0 +1,1179 @@ +"""HF README processing for the ``enrich_hf_metadata`` skill. + +Provides README cleaning for LLM injection, gallery/image extraction from +multiple formats (YAML widget, markdown, HTML ````, gallery tables), +and section-based README trimming for collection repos. +""" + +from __future__ import annotations + +import html as html_module +import re +from typing import List, Tuple + + +_REPO_URL_PATTERN = re.compile(r"https?://huggingface\.co/([^/]+/[^/]+)") + + +def extract_simple_markdown_images( + markdown_text: str, + repo: str, + existing_urls: set | None = None, + default_width: int = 512, + default_height: int = 512, +) -> list[dict]: + """Extract standalone markdown images from the README body. + + Matches ``![alt](url)`` on lines that are NOT part of a markdown table + and NOT inside fenced code blocks. These are common in DreamBooth + training dumps where the user uploads example images with simple + ``![img_0](./image_0.png)`` syntax. + + Returns a list of dicts in the same ``civitai.images`` format as + :func:`extract_gallery_images`. + """ + if not markdown_text or not repo: + return [] + + base_url = f"https://huggingface.co/{repo}/resolve/main" + images: list[dict] = [] + seen_urls: set = set(existing_urls) if existing_urls else set() + + # Collect lines that are NOT inside fenced code blocks + lines = markdown_text.split("\n") + in_code_block = False + n = len(lines) + i = 0 + while i < n: + line = lines[i] + if line.strip().startswith("```"): + in_code_block = not in_code_block + i += 1 + continue + if in_code_block: + i += 1 + continue + # Skip table rows + if "|" in line and i + 1 < n and re.match(r"^\|[\s:-]+\|", lines[i + 1]): + i += 1 + continue + + m = re.match(r"^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$", line) + if m: + raw_path = m.group(2).strip() + if raw_path.startswith("http"): + url = raw_path + else: + clean = raw_path.lstrip("./").lstrip("/") + url = f"{base_url}/{clean}" + if url not in seen_urls: + seen_urls.add(url) + images.append({ + "url": url, + "type": "image", + "nsfwLevel": 0, + "width": default_width, + "height": default_height, + "meta": {"prompt": m.group(1), "negativePrompt": ""}, + "hasMeta": bool(m.group(1)), + "hasPositivePrompt": bool(m.group(1)), + }) + i += 1 + + return images + + +def extract_html_img_tags( + markdown_text: str, + repo: str, + existing_urls: set | None = None, + default_width: int = 512, + default_height: int = 512, +) -> list[dict]: + """Extract image URLs from HTML ```` tags in the README. + + Many HF collection repos (e.g. ``deadman44/Z-Image_LoRA``) use raw HTML + ```` tags exclusively for their sample images, with no markdown + ``![]()`` equivalents. This function finds those tags and constructs + resolvable HF URLs. + + Returns a list of dicts in the ``civitai.images`` format. + """ + if not markdown_text or not repo: + return [] + + base_url = f"https://huggingface.co/{repo}/resolve/main" + images: list[dict] = [] + seen_urls: set = set(existing_urls) if existing_urls else set() + + for m in re.finditer( + r']*src=\"([^\"]+)\"', + markdown_text, + re.IGNORECASE, + ): + raw_path = m.group(1).strip() + if not raw_path: + continue + + if raw_path.startswith("http"): + url = raw_path + else: + clean = raw_path.lstrip("./").lstrip("/") + url = f"{base_url}/{clean}" + + if url and url not in seen_urls: + seen_urls.add(url) + images.append({ + "url": url, + "type": "image", + "nsfwLevel": 0, + "width": default_width, + "height": default_height, + "meta": {"prompt": "", "negativePrompt": ""}, + "hasMeta": False, + "hasPositivePrompt": False, + }) + + # Also try single-quoted src attributes + for m in re.finditer( + r"]*src='([^']+)'", + markdown_text, + re.IGNORECASE, + ): + raw_path = m.group(1).strip() + if not raw_path: + continue + if raw_path.startswith("http"): + url = raw_path + else: + clean = raw_path.lstrip("./").lstrip("/") + url = f"{base_url}/{clean}" + if url and url not in seen_urls: + seen_urls.add(url) + images.append({ + "url": url, + "type": "image", + "nsfwLevel": 0, + "width": default_width, + "height": default_height, + "meta": {"prompt": "", "negativePrompt": ""}, + "hasMeta": False, + "hasPositivePrompt": False, + }) + + return images + + +def extract_repo_from_hf_url(hf_url: str) -> str: + """Extract ``user/repo`` from a HuggingFace URL.""" + m = _REPO_URL_PATTERN.match(hf_url) + return m.group(1) if m else "" + + +def extract_gallery_images( + markdown_text: str, + repo: str, + default_width: int = 512, + default_height: int = 512, +) -> List[dict]: + """Extract widget/gallery images from the YAML frontmatter of a HF README. + + Args: + markdown_text: Raw README content. + repo: HF repo identifier (``user/repo``). + default_width: Fallback width when the README provides no dimension. + default_height: Fallback height when the README provides no dimension. + + Returns a list of dicts compatible with the ``civitai.images`` metadata + format, each containing ``url`` (absolute HF URL), ``meta.prompt``, + ``width``, ``height``, and ``type``. Returns an empty list when no + widget entries are found or when *repo* is empty. + """ + if not markdown_text or not repo: + return [] + + frontmatter = _extract_frontmatter(markdown_text) + if not frontmatter: + return [] + + images: List[dict] = [] + base_url = f"https://huggingface.co/{repo}/resolve/main" + w = default_width or 512 + h = default_height or 512 + + # Find the `widget:` section + widget_match = re.search(r"^widget:\s*$", frontmatter, re.MULTILINE) + if not widget_match: + return images + + # Split entries by YAML list marker `\n- `. Each entry is one widget list + # item with `output:` and optionally `text:` sub-keys. + entries_raw = frontmatter[widget_match.end():] + entries = re.split(r"\n- ", entries_raw) if "\n- " in entries_raw else [entries_raw] + + for entry in entries: + entry = entry.strip() + if not entry: + continue + + # Extract output.url — handle both `output:` and `- output:` (dash prefix) + url = "" + url_match = re.search( + r"^-?\s*output:\s*\n\s+url:\s*(.+?)\s*$", entry, re.MULTILINE + ) + if url_match: + raw_path = url_match.group(1).strip().strip("'\"") + if raw_path and not raw_path.startswith("http"): + url = f"{base_url}/{raw_path.lstrip('/')}" + elif raw_path.startswith("http"): + url = raw_path + + # Extract text (prompt) — from YAML `text:` sub-key + text = "" + text_match = re.search( + r"^-?\s*text:\s*(.+?)\s*$", entry, re.MULTILINE + ) + if text_match: + raw_text = text_match.group(1).strip().strip("'\"") + if raw_text and raw_text != "-": + # Handle YAML block scalar markers (>-, >, |, |-) where the + # actual text lives on subsequent indented lines. + if raw_text in (">", ">-", "|", "|-"): + text_lines: list[str] = [] + in_block = False + for line in entry.split("\n"): + stripped = line.strip() + if not in_block: + if stripped.endswith(raw_text): + in_block = True + continue + # Block content ends at a line with less indentation + # or a YAML key at the start of a line. + if not stripped or re.match(r"^\s*\w+:", line): + break + if stripped: + text_lines.append(stripped) + text = " ".join(text_lines) + else: + text = raw_text + + if url: + image: dict = { + "url": url, + "type": "image", + "nsfwLevel": 0, + "width": w, + "height": h, + "meta": {"prompt": text, "negativePrompt": ""}, + "hasMeta": bool(text), + "hasPositivePrompt": bool(text), + } + images.append(image) + + return images + + +def extract_gallery_table_images( + markdown_text: str, + repo: str, + existing_urls: set | None = None, + default_width: int = 512, + default_height: int = 512, +) -> list[dict]: + """Extract images from ``| Preview | Prompt |`` markdown gallery tables. + + Many HF READMEs include a sample-gallery table in the body (outside + the YAML frontmatter) that shows generation examples with their + prompts. This function parses those tables and merges results with + the widget-sourced images from :func:`extract_gallery_images`. + + Returns a list of dicts in the same ``civitai.images`` format as + :func:`extract_gallery_images`. Already-seen URLs (from *existing_urls*) + are skipped. + """ + if not markdown_text or not repo: + return [] + + base_url = f"https://huggingface.co/{repo}/resolve/main" + images: list[dict] = [] + seen_urls: set = set(existing_urls) if existing_urls else set() + lines = markdown_text.split("\n") + n = len(lines) + i = 0 + + while i < n: + line = lines[i] + if "|" not in line or i + 1 >= n: + i += 1 + continue + + # Check for table separator row + if not re.match(r"^\|[\s:-]+\|", lines[i + 1]): + i += 1 + continue + + header_lower = line.strip().lower() + first_cell = header_lower.strip("|").split("|")[0].strip() if "|" in header_lower else "" + is_gallery = any(kw in first_cell for kw in ("preview", "sample", "gallery", "image", "thumbnail")) + if not is_gallery: + i += 1 + continue + + # Skip header + separator + i += 2 + while i < n and "|" in lines[i]: + cells = [c.strip() for c in lines[i].strip().strip("|").split("|")] + if len(cells) >= 2: + first = cells[0] + prompt = cells[1] + + url_match = re.search(r"!\[([^\]]*)\]\(([^)]+)\)", first) + if url_match: + raw_path = url_match.group(2) + if raw_path.startswith("http"): + url = raw_path + else: + # Normalise: remove leading / and ./ prefixes + clean = raw_path.lstrip("./").lstrip("/") + url = f"{base_url}/{clean}" + + if url not in seen_urls: + seen_urls.add(url) + images.append({ + "url": url, + "type": "image", + "nsfwLevel": 0, + "width": default_width, + "height": default_height, + "meta": {"prompt": prompt, "negativePrompt": ""}, + "hasMeta": bool(prompt), + "hasPositivePrompt": bool(prompt), + }) + i += 1 + continue + + return images + + +def _extract_frontmatter(text: str) -> str: + """Return the YAML frontmatter content (without the ``---`` delimiters). + + Returns empty string when no frontmatter is found. + """ + if text.startswith("---"): + idx = text.find("---", 3) + if idx != -1: + return text[3:idx] + return "" + + +def convert_readme_to_html(markdown_text: str | None) -> str: + """Convert HF README markdown to sanitised HTML.""" + if not markdown_text: + return "" + + text = markdown_text + text = _strip_frontmatter(text) + text = _strip_gallery(text) + text = _strip_badge_images(text) + text = _strip_html_comments(text) + html = _md_to_html(text) + return html.strip() + + +# --------------------------------------------------------------------------- +# README cleaning for LLM prompt injection +# --------------------------------------------------------------------------- + +#: Section headers that signal boilerplate content with zero metadata value. +_BOILERPLATE_HEADERS: tuple[str, ...] = ( + "download model", + "license", + "citation", + "links", + "disclaimer", + "architecture notes", + "training details", + "dataset", + "provenance", +) + +#: Table header keywords that identify training-parameter tables. +_TRAINING_PARAM_KEYWORDS: tuple[str, ...] = ( + "lr scheduler", + "optimizer", + "network dim", + "network alpha", + "noise offset", + "multires noise", + "repeat", + "epoch", + "batch size", + "gradient accumulation", + "learning rate", + "rslora", + "dtype", +) + +#: Maximum chars before a single-line comma list is considered massive. +_MASSIVE_LIST_LINE_MIN_LEN = 150 +#: Minimum consecutive enumeration lines to trigger massive-list stripping. +_MASSIVE_LIST_THRESHOLD = 8 + + +def clean_readme_for_llm(markdown_text: str | None, max_length: int = 6000) -> str: + """Clean a HF README for injection into an LLM metadata-extraction prompt. + + Removes content that carries no signal for inferring base model, + trigger words, short description, tags, or a preview image URL: + + * ``widget:`` YAML block (example prompts + output URLs) + * ```` tags and wrappers + * Fenced code blocks (Python / bash / bibtex / yaml) + * Standalone ``![...](...)`` image lines and ```` tags + * Training-parameter tables + * Boilerplate sections (Download / License / Citation / …) + * Massive enumeration lists (e.g. 3000+ celebrity names) + + The post-processor still receives the **full** raw README via + ``readme_content_full``, so nothing is lost for HTML conversion or + gallery-image extraction. + + Args: + markdown_text: Raw README.md content from HuggingFace. + max_length: Hard ceiling on output length (default 6 000 chars). + + Returns: + Cleaned markdown, truncated to *max_length*. + """ + if not markdown_text: + return "" + + text = markdown_text + + # Order matters — broader strips first, then finer ones. + text = _strip_gallery(text) + text = _strip_widget_section(text) + text = _strip_fenced_code_blocks(text) + text = _strip_standalone_images(text) + text = _strip_training_tables(text) + text = _strip_boilerplate_sections(text) + text = _strip_massive_lists(text) + text = _strip_badge_images(text) + text = _strip_html_comments(text) + text = _compress_blank_lines(text) + + if len(text) > max_length: + text = text[:max_length] + + return text.strip() + + +#: Language tags that should be preserved (not stripped) because they +#: contain CLI commands, installation instructions, or shell snippets +#: that carry metadata signal (e.g. trigger word setup, model usage). +_PRESERVED_CODE_LANGS: frozenset[str] = frozenset({ + "bash", "sh", "shell", "console", "zsh", +}) + + +def _strip_fenced_code_blocks(text: str) -> str: + """Strip fenced code blocks that have an explicit programming-language tag. + + Blocks without a language tag (just `` ``` ``) are preserved — they + often contain trigger words, example prompts, or config snippets + rather than actual runnable code. + + Blocks tagged with shell languages (``bash``, ``sh``, ``shell``, + ``console``, ``zsh``) are also preserved — they frequently contain + CLI installation instructions or usage commands that carry signal for + LLM metadata extraction. + """ + # Match opening ``` immediately followed by a word character (the language + # tag) that is NOT a preserved shell language, then any content, then + # closing ```. Plain ``` at the start of a line is left intact. A + # leading \n is optional (handles blocks at the start of the text). + return re.sub( + r"(?:\n|^)```(?!(?:" + "|".join(_PRESERVED_CODE_LANGS) + r")\b)[a-zA-Z_][a-zA-Z0-9_]*\s*\n.*?\n```", + "", + text, + flags=re.DOTALL, + ) + + +def _strip_standalone_images(text: str) -> str: + """Strip/compress image embeds for LLM-prompt injection. + + Markdown images (``![alt](url)``) are **kept intact** so the LLM can + extract a ``preview_url`` from them. Only the alt text is needed for + content signal; the URL is needed for image extraction. + + HTML ```` tags on their own line are **converted to markdown + image syntax** ``![alt](src)`` so both the alt text and the image URL + are preserved in a format the LLM can easily extract. Previously the + URL was stripped entirely, making it impossible for the LLM to return + a ``preview_url`` for repos that use HTML ```` tags exclusively. + """ + def _img_to_md(match: re.Match) -> str: + """Convert an ```` tag to markdown image syntax ``![alt](src)``.""" + tag = match.group(0) + src_m = re.search(r'src="([^"]+)"', tag) or re.search(r"src='([^']+)'", tag) + if not src_m: + return "" + src = src_m.group(1) + alt_m = re.search(r'alt="([^"]*)"', tag) or re.search(r"alt='([^']*)'", tag) + alt = alt_m.group(1) if alt_m else "" + return f"![{alt}]({src})" + + text = re.sub( + r'^\s*]+/?>(?:)?\s*$', + _img_to_md, + text, + flags=re.MULTILINE | re.IGNORECASE, + ) + return text + + +def _strip_training_tables(text: str) -> str: + """Strip markdown tables whose header row mentions training parameters. + + Checks the header row (first line of a detected table) against + ``_TRAINING_PARAM_KEYWORDS``. Non-training tables (e.g. "Best + Dimensions") are preserved. + """ + lines = text.split("\n") + out: list[str] = [] + i = 0 + n = len(lines) + + while i < n: + line = lines[i] + if "|" in line and i + 1 < n and re.match(r"^\|[\s:-]+\|", lines[i + 1]): + table_lines = [line] + i += 1 + while i < n and "|" in lines[i]: + table_lines.append(lines[i]) + i += 1 + + # Check header + first data row for training keywords + header_and_first = (line + "\n" + (table_lines[2] if len(table_lines) > 2 else "")).lower() + if any(kw in header_and_first for kw in _TRAINING_PARAM_KEYWORDS): + continue + out.extend(table_lines) + else: + out.append(line) + i += 1 + + return "\n".join(out) + + +def _strip_boilerplate_sections(text: str) -> str: + """Strip sections whose headings match known boilerplate patterns. + + When a heading (``## Download model``, ``## License``, etc.) is + detected, the heading and all content until the next heading of + equal-or-higher level is removed. + """ + lines = text.split("\n") + out: list[str] = [] + i = 0 + n = len(lines) + skip_until_level: int | None = None + + while i < n: + line = lines[i] + h_match = re.match(r"^(#{1,4})\s+(.+?)\s*#*$", line) + if h_match: + level = len(h_match.group(1)) + title = h_match.group(2).strip().lower() + + is_boilerplate = any( + title == kw or title.startswith(kw + " ") or title.startswith(kw + ":") + for kw in _BOILERPLATE_HEADERS + ) + + if is_boilerplate: + skip_until_level = level + i += 1 + continue + + if skip_until_level is not None and level <= skip_until_level: + skip_until_level = None + + if skip_until_level is None: + out.append(line) + i += 1 + + return "\n".join(out) + + +def _strip_massive_lists(text: str) -> str: + """Strip blocks of 8+ consecutive enumeration-style lines. + + Targets long comma-separated name lists (e.g. the 3000+ celebrity + names in some Z-Image READMEs) and dense bullet enumerations. + """ + lines = text.split("\n") + out: list[str] = [] + i = 0 + n = len(lines) + + while i < n: + stripped = lines[i].strip() + + # A "list-like" line ends with comma or is a bullet with commas + is_list_like = bool(stripped) and ( + stripped.endswith(",") + or len(stripped) >= _MASSIVE_LIST_LINE_MIN_LEN + or (bool(re.match(r"^[-*+]\s", stripped)) and "," in stripped) + ) + + if is_list_like: + count = 1 + j = i + 1 + while j < n: + s = lines[j].strip() + if not s: + j += 1 + continue + if s.endswith(",") or (bool(re.match(r"^[-*+]\s", s)) and "," in s): + count += 1 + j += 1 + else: + break + + if count >= _MASSIVE_LIST_THRESHOLD: + i = j + continue + + out.append(lines[i]) + i += 1 + + return "\n".join(out) + + +def extract_relevant_section( + readme_content: str, + model_basename: str, + *, + context_lines: int = 30, +) -> str: + """Find the section of a HuggingFace README relevant to a specific model file. + + Many HF repos bundle multiple model files (LoRA collections) in a single + repo. This function trims the README to only the portion that references + the given *model_basename* (e.g. ``lora_zimage_myjs_turbo_beta01``), + dramatically reducing prompt length and focusing the LLM on the correct + metadata. + + Matching strategies, tried in order: + + 1. **Download link** — a markdown or HTML link whose URL contains the + basename. Returns the surrounding block (previous heading → next + heading). + 2. **Anchor ID** — an ```` whose ``id`` matches a token from + the basename (split on ``_``/``-``). + 3. **Section heading** — an ``

``-``

`` or markdown heading whose + text overlaps with tokens from the basename. + 4. **Fallback** — the full README unchanged. + + Args: + readme_content: Raw README.md content. + model_basename: Model file basename *without* extension (e.g. + ``lora_zimage_myjs_turbo_beta01``). + context_lines: Number of lines of context before/after a matched + download link to include (default 30). + + Returns: + Trimmed README text, or the original when no matching section is + found. + """ + if not readme_content or not model_basename: + return readme_content or "" + + lines = readme_content.split("\n") + n = len(lines) + basename_lower = model_basename.lower() + # Tokens from the basename split on common separators. + # Exclude tokens of length ≤ 3 — 2-3 char tokens (e.g. "cry", "myjs") + # are too short to discriminate between different models in collection repos. + tokens = {t for t in re.split(r"[_\-.\s]+", basename_lower) if len(t) > 3} + + # ------------------------------------------------------------------ + # Strategy 1: Find a download link containing the basename + # ------------------------------------------------------------------ + for idx, line in enumerate(lines): + # Match markdown links: [text](url) and HTML links: + # whose URL contains the basename. + if basename_lower in line.lower() and _looks_like_download_link(line): + return _extract_section(lines, idx, context_lines) + + # ------------------------------------------------------------------ + # Strategy 2: Find an anchor ID matching a token + # ------------------------------------------------------------------ + for idx, line in enumerate(lines): + m = re.search(r', , , + hm = re.search(r']*>(.+?)', line, re.IGNORECASE) + heading_text = "" + if hm: + heading_text = hm.group(1) + else: + # Markdown heading: ## text + mm = re.match(r"^#{1,4}\s+(.+?)\s*#*$", line) + if mm: + heading_text = mm.group(1) + if heading_text: + # Skip TOC-style entries where the heading text is a markdown + # link or bullet list item (e.g. "### - [model_name](url)"). + # These are table-of-contents entries, not real section headers. + stripped = heading_text.strip() + if stripped.startswith("- [") or re.match(r"^\[.+?\]\(.+?\)", stripped): + continue + + heading_lower = heading_text.lower() + # Require at least 2 token overlaps, or the full basename as a + # substring of the heading. A single 4-5 char token match is + # too weak — e.g. "devil" matching "dante_devil_may_cry" when + # the actual model is "vergil_devil_may_cry", or "image" matching + # a table-of-contents heading. + matching = [t for t in tokens if t in heading_lower] + if len(matching) >= 2 or basename_lower in heading_lower: + section = _extract_section(lines, idx, context_lines) + # Verify the section contains the model name — headings in + # TOC areas can match tokens but produce a tiny irrelevant + # section (e.g. "### - [z_image_turbo](url)" matched by + # tokens "lora" and "turbo"). + if basename_lower in section.lower() or len(section) > max(500, context_lines * 20): + return section + + # ------------------------------------------------------------------ + # Fallback: return FULL readme + # ------------------------------------------------------------------ + return readme_content + + +def _looks_like_download_link(line: str) -> bool: + """Heuristic: does *line* look like it contains a model-file download link?""" + # Markdown: [text](url) + if re.search(r'\[([^\]]*)\]\(([^)]*\.safetensors[^)]*)\)', line): + return True + # Markdown: [text](url) containing "download" or "resolve" + if re.search(r'\[([^\]]*)\]\(([^)]*/(resolve|download)/[^)]*)\)', line): + return True + # HTML + if re.search(r']*href="[^"]*\.safetensors', line, re.IGNORECASE): + return True + return False + + +def _heading_level(line: str) -> int: + """Return the heading level of *line* (1-4), or 0 if not a heading.""" + stripped = line.strip() + m = re.match(r"^(#{1,4})\s", stripped) + if m: + return len(m.group(1)) + m = re.match(r"^)", stripped, re.IGNORECASE) + if m: + return int(m.group(1)) + return 0 + + +def _extract_section( + lines: list[str], match_idx: int, context_lines: int, +) -> str: + """Return a window of *lines* around *match_idx* bounded by headings. + + When *match_idx* is itself a heading line, the section starts *at* + that heading (no backward walk), avoiding pulling in content from + earlier sibling sections. The forward walk stops at a heading of + **equal or higher** level (e.g. a ``# Title`` match includes all its + ``## Children``). + + When *match_idx* is **not** a heading (e.g. a download link matched + inside a sub-section like ``# Download``), the forward walk uses a + generous line-count-based window instead of stopping at the very next + heading. This prevents same-level sub-headings (e.g. ``# Download``, + ``# Trigger``, ``# Sample prompt`` within a single model section) + from prematurely truncating the window before sample images. + + Always includes the YAML frontmatter if the original lines contain one, + because it carries critical metadata (``base_model``, ``tags``, + ``instance_prompt``) that the LLM needs regardless of which section + matches. + """ + n = len(lines) + + # Determine start — if match is a heading, start right there + if _is_heading(lines[match_idx]): + start = match_idx + match_level = _heading_level(lines[match_idx]) + else: + match_level = 0 + start = max(0, match_idx - context_lines) + for i in range(match_idx - 1, max(-1, match_idx - context_lines * 3), -1): + if i < 0: + start = 0 + break + if _is_heading(lines[i]): + start = i + break + + # Walk forward. + end = n + if match_level == 0: + # Non-heading match (e.g. a download link). Use a line-based + # window so that same-level sub-headings (# Download, # Trigger, + # # Sample prompt within a single model section) don't truncate + # the window. Stop at the next anchor (which + # typically starts a new model section in collection repos), or + # fall back to a generous line limit. + forward_limit = min(n, match_idx + max(context_lines * 3, 250)) + for i in range(match_idx + 1, forward_limit): + if re.search(r' 0 and hl <= match_level: + end = i + break + + # If YAML frontmatter exists before the matched section, prepend it. + if start > 0 and len(lines) > 1 and lines[0].strip() == "---": + for i in range(1, min(start, len(lines))): + if lines[i].strip() == "---": + yaml_section = "\n".join(lines[:i+1]) + return yaml_section + "\n" + "\n".join(lines[start:end]) + + return "\n".join(lines[start:end]) + + +def _is_heading(line: str) -> bool: + """Return True if *line* is a markdown or HTML heading (not a closing tag).""" + stripped = line.strip() + if re.match(r"^#{1,4}\s", stripped): + return True + if re.match(r"^)", stripped, re.IGNORECASE): + return True + return False + + +def _compress_blank_lines(text: str) -> str: + """Collapse runs of 3+ blank lines down to 2.""" + return re.sub(r"\n{3,}", "\n\n", text) + + +# --------------------------------------------------------------------------- +# Pre-processing: strip unwanted sections (HTML conversion helpers) +# --------------------------------------------------------------------------- + + +def _strip_frontmatter(text: str) -> str: + if text.startswith("---"): + idx = text.find("---", 3) + if idx != -1: + return text[idx + 3 :] + return text + + +def _strip_gallery(text: str) -> str: + text = re.sub( + r"]*/>|]*>.*?", + "", + text, + flags=re.IGNORECASE | re.DOTALL, + ) + text = re.sub( + r']*flex[^>]*>.*?(?:]*gallery).*?', + "", + text, + flags=re.IGNORECASE | re.DOTALL, + ) + return text + + +def _strip_widget_section(text: str) -> str: + """Strip the ``widget:`` YAML block from the README frontmatter. + + The widget section contains verbose example prompts (``text: >-`` entries) + that are useful for post-processor gallery image extraction but carry + no signal for LLM metadata extraction. Stripping them dramatically + reduces prompt size (e.g. 2800+ chars → ~100 chars) and lets the LLM + focus on the actual YAML metadata fields (``base_model``, ``tags``, + ``instance_prompt``, etc.). + """ + # Match widget: through the end of the frontmatter (the closing ---) + # or until the next YAML top-level key. + return re.sub( + r"\nwidget:.*?(?=\n\w+:|\n---)", + "", + text, + flags=re.DOTALL, + ) + + +def _strip_badge_images(text: str) -> str: + badge_keywords = ( + "badge", "shield", "logo", "icon", "download", "license", + "python", "version", "status", "build", "test", "coverage", + "docker", "pypi", "npm", "github", "hugging face", "discord", + "twitter", "colab", "gradio", "space", + ) + + def _should_remove(m: re.Match) -> str: + alt = (m.group(1) or "").lower() + for kw in badge_keywords: + if kw in alt: + return "" + return m.group(0) + + text = re.sub(r"!\[([^\]]*)\]\([^)]+\)", _should_remove, text) + return text + + +def _strip_html_comments(text: str) -> str: + return re.sub(r"", "", text, flags=re.DOTALL) + + +# --------------------------------------------------------------------------- +# Markdown → HTML rendering +# --------------------------------------------------------------------------- + + +def _md_to_html(text: str) -> str: + """Convert a limited markdown subset to HTML. + + Handles: h1-h4, paragraphs, bold, italic, inline code, fenced code + blocks, unordered/ordered lists, links, images (→ alt text), hr, + blockquotes, and tables. + """ + lines = text.split("\n") + out: list[str] = [] + i = 0 + n = len(lines) + + while i < n: + line = lines[i] + + # Fenced code block + if line.startswith("```"): + lang = line[3:].strip() + code_lines: list[str] = [] + i += 1 + while i < n and not lines[i].startswith("```"): + code_lines.append(lines[i]) + i += 1 + i += 1 + code_escaped = html_module.escape("\n".join(code_lines)) + lang_attr = f' class="language-{html_module.escape(lang)}"' if lang else "" + out.append(f"
{code_escaped}\n
") + continue + + # Horizontal rule + if re.match(r"^-{3,}\s*$", line) or re.match(r"^\*{3,}\s*$", line): + out.append("
") + i += 1 + continue + + # ATX headers + h_match = re.match(r"^(#{1,4})\s+(.+?)\s*#*$", line) + if h_match: + level = len(h_match.group(1)) + content = _inline_md(h_match.group(2)) + out.append(f"{content}") + i += 1 + continue + + # Blockquote + if line.startswith("> "): + bq_lines: list[str] = [] + while i < n and lines[i].startswith("> "): + bq_lines.append(lines[i][2:]) + i += 1 + bq_html = _md_to_html("\n".join(bq_lines)) + out.append(f"
{bq_html}
") + continue + + # Unordered list + if re.match(r"^[-*+]\s", line): + items, i = _parse_list(lines, i, r"^[-*+]\s") + list_html = "".join(f"
  • {_inline_md(item)}
  • " for item in items) + out.append(f"
      {list_html}
    ") + continue + + # Ordered list + if re.match(r"^\d+\.\s", line): + items, i = _parse_list(lines, i, r"^\d+\.\s") + list_html = "".join(f"
  • {_inline_md(item)}
  • " for item in items) + out.append(f"
      {list_html}
    ") + continue + + # Table + if "|" in line and i + 1 < n and re.match(r"^\|[\s:-]+\|", lines[i + 1]): + table_html, i = _parse_table(lines, i) + out.append(table_html) + continue + + # Indented code block (4 spaces) — only when content is non-whitespace + if (line.startswith(" ") or line.startswith("\t")) and line.strip(): + code_lines, i = _collect_indented_code(lines, i) + if code_lines: + code_escaped = html_module.escape("\n".join(code_lines)) + out.append(f"
    {code_escaped}\n
    ") + continue + + # Whitespace-only indented line — treat as paragraph separator + if (line.startswith(" ") or line.startswith("\t")) and not line.strip(): + i += 1 + continue + + # Empty line + if not line.strip(): + i += 1 + continue + + # Paragraph (collect consecutive non-empty lines) + para_lines: list[str] = [] + while i < n: + l = lines[i] + if not l.strip(): + break + if re.match(r"^(#{1,4}\s|```|---|\*{3,}|\d+\.\s|[-*+]\s|>\s|\|)", l): + break + para_lines.append(l) + i += 1 + para = " ".join(p.strip() for p in para_lines if p.strip()) + if para: + out.append(f"

    {_inline_md(para)}

    ") + continue + + return "\n".join(out) + + +def _collect_indented_code(lines: list[str], start: int) -> Tuple[List[str], int]: + """Collect lines of an indented code block starting at *start*. + + Returns (code_lines, next_index). Whitespace-only lines within the + block are preserved; a block whose *only* content is whitespace + returns an empty list. + """ + code_lines: list[str] = [] + # Include the first line + if lines[start].strip(): + code_lines.append(lines[start]) + i = start + 1 + n = len(lines) + while i < n and (lines[i].startswith(" ") or lines[i].startswith("\t") or lines[i] == ""): + if lines[i].strip() or code_lines: + code_lines.append(lines[i]) + i += 1 + # If the block never had real content, discard it + if not any(ln.strip() for ln in code_lines): + return [], start + 1 + return code_lines, i + + +def _inline_md(text: str) -> str: + """Convert inline markdown within a paragraph/heading. + + Each pattern independently HTML-escapes its captured content, so no + global pre-escape is needed. This avoids double-escaping issues + (e.g. `` `a < b` `` → ``a < b``, not ``&lt;``). + """ + # Image: ![alt](url) → alt text + text = re.sub( + r"!\[([^\]]*)\]\([^)]+\)", + lambda m: html_module.escape(m.group(1) or ""), + text, + ) + + # Link: [text](url) →
    text + text = re.sub( + r"\[([^\]]+)\]\(([^)]+)\)", + lambda m: f'{html_module.escape(m.group(1))}', + text, + ) + + text = re.sub( + r"\*\*(.+?)\*\*", + lambda m: f"{html_module.escape(m.group(1))}", + text, + ) + text = re.sub( + r"(?{html_module.escape(m.group(1))}", + text, + ) + text = re.sub( + r"`([^`]+)`", + lambda m: f"{html_module.escape(m.group(1))}", + text, + ) + text = re.sub( + r"~~(.+?)~~", + lambda m: f"{html_module.escape(m.group(1))}", + text, + ) + return text + + +def _parse_list(lines: list[str], start: int, pattern: str) -> Tuple[List[str], int]: + """Collect list items starting at *start* matching *pattern*.""" + items: list[str] = [] + i = start + while i < len(lines): + m = re.match(pattern, lines[i]) + if m: + item = lines[i][m.end():].strip() + i += 1 + while i < len(lines) and lines[i].strip() and not re.match( + r"^(#{1,4}\s|```|\d+\.\s|[-*+]\s)", lines[i] + ): + item += " " + lines[i].strip() + i += 1 + items.append(item) + else: + break + return items, i + + +def _parse_table(lines: list[str], start: int) -> Tuple[str, int]: + """Parse a markdown table starting at *start*.""" + header_line = lines[start] + i = start + 2 # skip separator line + + headers = [c.strip() for c in header_line.strip().strip("|").split("|")] + rows: list[str] = [] + while i < len(lines) and "|" in lines[i]: + cells = [c.strip() for c in lines[i].strip().strip("|").split("|")] + cells_html = "".join(f"{_inline_md(c)}" for c in cells) + rows.append(f"{cells_html}") + i += 1 + + headers_html = "".join(f"{_inline_md(h)}" for h in headers) + table = f"{headers_html}" + table += "".join(rows) + table += "
    " + return table, i diff --git a/py/services/civitai_base_model_service.py b/py/services/civitai_base_model_service.py index 4bce7780..1cd61e50 100644 --- a/py/services/civitai_base_model_service.py +++ b/py/services/civitai_base_model_service.py @@ -213,6 +213,18 @@ class CivitaiBaseModelService: "wan video 2.2 i2v-a14b": "WAN", "wan video 2.5 t2v": "WAN", "wan video 2.5 i2v": "WAN", + "wan video 2.7": "WAN", + "wan image 2.7": "WI27", + "ace audio": "ACE", + "boogu": "BOOG", + "grok": "GROK", + "happyhorse": "HAPP", + "hidream-o1": "HIO1", + "lens": "LENS", + "mai": "MAI", + "upscaler": "UPSC", + "ideogram 4.0": "ID40", + "qwen 2": "QWN2", } if lower_name in special_cases: @@ -392,6 +404,7 @@ class CivitaiBaseModelService: "LTXV2", "LTXV 2.3", "CogVideoX", + "HappyHorse", "Mochi", "Hunyuan Video", "Wan Video", @@ -404,15 +417,25 @@ class CivitaiBaseModelService: "Wan Video 2.2 I2V-A14B", "Wan Video 2.5 T2V", "Wan Video 2.5 I2V", + "Wan Image 2.7", + "Wan Video 2.7", ], "Other Models": [ + "ACE Audio", "Illustrious", "Pony", "Pony V7", + "Boogu", "HiDream", + "HiDream-O1", + "Ideogram 4.0", "Qwen", + "Qwen 2", "AuraFlow", "Chroma", + "Grok", + "Lens", + "MAI", "ZImageTurbo", "ZImageBase", "PixArt a", @@ -426,6 +449,7 @@ class CivitaiBaseModelService: "Ernie Turbo", "Nucleus", "Krea 2", + "Upscaler", ], } diff --git a/py/services/errors.py b/py/services/errors.py index 581b43c6..930febcd 100644 --- a/py/services/errors.py +++ b/py/services/errors.py @@ -25,3 +25,21 @@ class ResourceNotFoundError(RuntimeError): pass + +class LLMNotConfiguredError(RuntimeError): + """Raised when an LLM-dependent operation is attempted but no provider is configured.""" + + pass + + +class LLMRateLimitError(RateLimitError): + """Raised when the LLM provider rejects a request due to rate limiting.""" + + pass + + +class LLMResponseError(RuntimeError): + """Raised when the LLM returns an unparseable or schema-invalid response.""" + + pass + diff --git a/py/services/llm_service.py b/py/services/llm_service.py new file mode 100644 index 00000000..b6854302 --- /dev/null +++ b/py/services/llm_service.py @@ -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 diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 64a0320e..b6cb40f1 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -107,6 +107,11 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "backup_retention_count": 5, "use_new_license_icons": True, "group_by_model": False, + # AI / LLM provider configuration (BYOK) + "llm_provider": "openai", # "openai" | "ollama" | "custom" + "llm_api_key": "", + "llm_api_base": "", # empty = provider default + "llm_model": "", # e.g. "gpt-4o-mini" } @@ -873,6 +878,23 @@ class SettingsManager: self.settings["civitai_api_key"] = env_api_key self._save_settings() + # LLM provider overrides + llm_env_map = { + "LLM_API_KEY": "llm_api_key", + "LLM_MODEL": "llm_model", + "LLM_API_BASE": "llm_api_base", + "LLM_PROVIDER": "llm_provider", + } + llm_changed = False + for env_var, settings_key in llm_env_map.items(): + env_val = os.environ.get(env_var) + if env_val: + logger.info("Found %s environment variable", env_var) + self.settings[settings_key] = env_val + llm_changed = True + if llm_changed: + self._save_settings() + def _default_settings_actions(self) -> List[Dict[str, Any]]: return [ { diff --git a/py/utils/constants.py b/py/utils/constants.py index ec569fab..99fcc5d9 100644 --- a/py/utils/constants.py +++ b/py/utils/constants.py @@ -226,9 +226,21 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset( "Wan Video 2.5 I2V", "Hunyuan Video", "Anima", + "ACE Audio", + "Boogu", "Ernie", "Ernie Turbo", - "Nucleus", + "Grok", + "HappyHorse", + "HiDream-O1", + "Ideogram 4.0", "Krea 2", + "Lens", + "MAI", + "Nucleus", + "Qwen 2", + "Upscaler", + "Wan Image 2.7", + "Wan Video 2.7", ] ) diff --git a/py/utils/models.py b/py/utils/models.py index 14d639f6..3b95a35c 100644 --- a/py/utils/models.py +++ b/py/utils/models.py @@ -35,6 +35,9 @@ class BaseModelMetadata: metadata_source: Optional[str] = None # Last provider that supplied metadata last_checked_at: float = 0 # Last checked timestamp hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed + trainedWords: List[str] = field( + default_factory=list + ) # Trigger words / activation prompts (source-agnostic) _unknown_fields: Dict[str, Any] = field( default_factory=dict, repr=False, compare=False ) # Store unknown fields @@ -47,6 +50,9 @@ class BaseModelMetadata: if self.tags is None: self.tags = [] + if self.trainedWords is None: + self.trainedWords = [] + @classmethod def from_dict(cls, data: Dict) -> "BaseModelMetadata": """Create instance from dictionary""" diff --git a/static/css/components/menu.css b/static/css/components/menu.css index b73698bd..1fee64cd 100644 --- a/static/css/components/menu.css +++ b/static/css/components/menu.css @@ -40,6 +40,12 @@ margin: 3px 0; } +.context-menu-item.disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} + .context-menu-item.delete-item { color: var(--danger-color); } diff --git a/static/css/components/modal/settings-modal.css b/static/css/components/modal/settings-modal.css index 392fef71..0f4339ff 100644 --- a/static/css/components/modal/settings-modal.css +++ b/static/css/components/modal/settings-modal.css @@ -1592,3 +1592,45 @@ input:checked + .toggle-slider:before { animation: settings-highlight-pulse 1.5s ease-in-out 3; border-radius: var(--border-radius-xs); } + +/* ---- Combobox panel for AI Provider settings ---- */ +/* The panel is appended to by Combobox.js and positioned relative to + the enhanced . 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; +} diff --git a/static/js/components/Combobox.js b/static/js/components/Combobox.js new file mode 100644 index 00000000..fe013e58 --- /dev/null +++ b/static/js/components/Combobox.js @@ -0,0 +1,394 @@ +// Combobox.js — Reusable dropdown-suggestion + free-text input component. +// +// Enhances an existing 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 to enhance. + * @param {Object} options + * @param {string[]} [options.presets=[]] Static preset values shown in dropdown. + * @param {(inputValue: string) => Promise} [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 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 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 + +

    {{ t('settings.sections.downloads') }}

    diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..9ac3dd04 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Test suite package. diff --git a/tests/enrich_hf_validation/__init__.py b/tests/enrich_hf_validation/__init__.py new file mode 100644 index 00000000..640fa86c --- /dev/null +++ b/tests/enrich_hf_validation/__init__.py @@ -0,0 +1 @@ +# HF Metadata Enrichment validation suite. diff --git a/tests/enrich_hf_validation/config.py b/tests/enrich_hf_validation/config.py new file mode 100644 index 00000000..49fec9c1 --- /dev/null +++ b/tests/enrich_hf_validation/config.py @@ -0,0 +1,133 @@ +"""Configuration for the HF metadata enrichment validation suite. + +Loads user settings, defines paths, and pulls constants from the main +codebase (``py.utils.constants``). +""" + +from __future__ import annotations + +import json +import logging +import os +from typing import Any, Dict, List + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Default paths +# --------------------------------------------------------------------------- + +_DEFAULT_MODELS_FILE = os.path.join( + os.path.dirname(__file__), "test_data", "hf_lora_models_with_safetensors.txt" +) +_DEFAULT_SETTINGS_PATH = os.path.expanduser( + "~/.config/ComfyUI-LoRA-Manager/settings.json" +) +_DEFAULT_OUTPUT_DIR = "/tmp/hf_enrich_validation" + +# --------------------------------------------------------------------------- +# Constants from the main codebase (copied at import time) +# --------------------------------------------------------------------------- + +# Priority tags used in the LLM prompt for tag selection guidance. +CIVITAI_MODEL_TAGS: List[str] = [ + "character", "concept", "clothing", "realistic", "anime", "toon", + "furry", "style", "poses", "background", "tool", "vehicle", + "buildings", "objects", "assets", "animal", "action", +] + +# --------------------------------------------------------------------------- +# Base model resolution — dynamically fetched from production code +# --------------------------------------------------------------------------- + +# Module-level cache — populated by init_supported_base_models(). +# Falls back to a comprehensive hardcoded list when the live fetch fails. +SUPPORTED_BASE_MODELS: List[str] = [] + +# Fallback base models when the production list_base_models() is unavailable. +_FALLBACK_BASE_MODELS: List[str] = [ + "SD 1.4", "SD 1.5", "SD 1.5 LCM", "SD 1.5 Hyper", + "SD 2.0", "SD 2.1", + "SD 3", "SD 3.5", "SD 3.5 Medium", "SD 3.5 Large", "SD 3.5 Large Turbo", + "SDXL 1.0", "SDXL Lightning", "SDXL Hyper", + "Flux.1 D", "Flux.1 S", "Flux.1 Krea", "Flux.1 Kontext", + "Flux.2 D", "Flux.2 Klein 9B", "Flux.2 Klein 9B-base", + "Flux.2 Klein 4B", "Flux.2 Klein 4B-base", + "AuraFlow", "Chroma", "PixArt a", "PixArt E", + "Hunyuan 1", "Lumina", "Kolors", + "NoobAI", "Illustrious", "Pony", "Pony V7", + "HiDream", "Qwen", "ZImageTurbo", "ZImageBase", + "SVD", "LTXV", "LTXV2", "LTXV 2.3", + "CogVideoX", "Mochi", + "Wan Video", "Wan Video 1.3B t2v", "Wan Video 14B t2v", + "Wan Video 14B i2v 480p", "Wan Video 14B i2v 720p", + "Wan Video 2.2 TI2V-5B", "Wan Video 2.2 T2V-A14B", + "Wan Video 2.2 I2V-A14B", + "Wan Video 2.5 T2V", "Wan Video 2.5 I2V", + "Hunyuan Video", "Anima", "Ernie", "Ernie Turbo", + "Nucleus", "Krea 2", +] + + +async def init_supported_base_models() -> None: + """Populate ``SUPPORTED_BASE_MODELS`` from the production codebase. + + Calls ``py.metadata_ops.list_base_models()`` which merges a hardcoded + fallback with models fetched from the CivitAI API. When the call + fails (e.g. offline, API error), falls back to ``_FALLBACK_BASE_MODELS``. + + Must be called from within an async event loop (i.e. during + ``run_validation.main()``, not at module level). + """ + try: + from py.metadata_ops import list_base_models + + models = await list_base_models() + if models: + SUPPORTED_BASE_MODELS[:] = models + logger.info("Loaded %d base models from production code", len(models)) + return + logger.warning("list_base_models returned empty list, using fallback") + except Exception as exc: + logger.warning("Failed to load base models from production: %s", exc) + + SUPPORTED_BASE_MODELS[:] = _FALLBACK_BASE_MODELS + logger.info("Using fallback base model list (%d entries)", len(SUPPORTED_BASE_MODELS)) + + +# Placeholder values the LLM sometimes emits that should count as "empty". +PLACEHOLDER_VALUES = frozenset({ + "none", "null", "n/a", "unknown", "not available", + "not specified", "no trigger words", "no trigger word", +}) + + +# --------------------------------------------------------------------------- +# User settings loader +# --------------------------------------------------------------------------- + + +def load_settings(settings_path: str) -> Dict[str, Any]: + """Load LoRA Manager settings from *settings_path*. + + Returns a flat dict with the LLM configuration fields that the + enrichment pipeline depends on. + """ + path = os.path.expanduser(settings_path) + if not os.path.exists(path): + raise FileNotFoundError( + f"Settings file not found: {path}\n" + "Please provide a valid --settings path." + ) + + with open(path, "r", encoding="utf-8") as fh: + raw: Dict[str, Any] = json.load(fh) + + # Extract LLM-relevant config + return { + "llm_provider": raw.get("llm_provider", "ollama"), + "llm_model": raw.get("llm_model", "qwen3.5:9b"), + "llm_api_base": raw.get("llm_api_base", "http://localhost:11434/v1"), + "llm_api_key": raw.get("llm_api_key", ""), + "settings_path": path, + } diff --git a/tests/enrich_hf_validation/enrichment_runner.py b/tests/enrich_hf_validation/enrichment_runner.py new file mode 100644 index 00000000..26e1b049 --- /dev/null +++ b/tests/enrich_hf_validation/enrichment_runner.py @@ -0,0 +1,208 @@ +"""Execute the ``enrich_hf_metadata`` skill serially over a list of models. + +Design decisions (local Ollama, no rate limits): + +- Sequential execution: one model at a time. 100 models at ~30-90 s/call + → roughly 1-2 h total. +- Progress persisted to a JSON checkpoint file so the run can be resumed + with ``--resume``. +- Per-model timeout guards against a stuck Ollama inference. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import time +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +_SKILL_NAME = "enrich_hf_metadata" + +# How long to wait for a single LLM call before marking it timed-out. +_PER_MODEL_TIMEOUT = 240 # seconds + +# --------------------------------------------------------------------------- +# Progress checkpoint helpers +# --------------------------------------------------------------------------- + +_PROGRESS_FILE = "progress.json" + + +def _load_progress(output_dir: str) -> Dict[str, Any]: + path = os.path.join(output_dir, _PROGRESS_FILE) + if os.path.exists(path): + with open(path, "r") as fh: + return json.load(fh) + return {"completed": [], "failed": [], "timed_out": []} + + +def _save_progress(output_dir: str, progress: Dict[str, Any]) -> None: + path = os.path.join(output_dir, _PROGRESS_FILE) + with open(path, "w") as fh: + json.dump(progress, fh, indent=2) + + +# --------------------------------------------------------------------------- +# Core runner +# --------------------------------------------------------------------------- + + +class EnrichmentRunner: + """Serial enrichment runner with checkpoint resume.""" + + def __init__( + self, + output_dir: str, + *, + per_model_timeout: int = _PER_MODEL_TIMEOUT, + ) -> None: + self._output_dir = output_dir + self._per_model_timeout = per_model_timeout + self._agent_service: Optional[Any] = None + + async def _ensure_agent_service(self) -> Any: + """Lazy-init AgentService (expensive — needs LLMService init).""" + if self._agent_service is not None: + return self._agent_service + from py.services.agent.agent_service import AgentService + + self._agent_service = await AgentService.get_instance() + return self._agent_service + + async def run( + self, + model_paths: List[str], + repos: List[str], + ) -> Dict[str, Any]: + """Run enrichment over *model_paths* (one-by-one). + + Args: + model_paths: model paths in the same order as *repos*. + repos: HF repo IDs (for display / checkpoint labelling). + + Returns: + A dict with keys ``results``, ``progress``, ``durations``. + """ + assert len(model_paths) == len(repos) + + progress = _load_progress(self._output_dir) + completed_set = set(progress["completed"]) + failed_set = set(progress["failed"]) + timed_out_set = set(progress.get("timed_out", [])) + + agent = await self._ensure_agent_service() + results: List[Dict[str, Any]] = [] + durations: Dict[str, float] = {} + + total = len(model_paths) + processed_before = len(completed_set | failed_set | timed_out_set) + + logger.info( + "Enrichment runner: %d models total, %d already processed", + total, + processed_before, + ) + + for idx, (model_path, repo_id) in enumerate(zip(model_paths, repos)): + if repo_id in completed_set: + logger.info("[%d/%d] SKIP (already done): %s", idx + 1, total, repo_id) + continue + if repo_id in failed_set or repo_id in timed_out_set: + logger.info( + "[%d/%d] SKIP (previously failed/timeout): %s", + idx + 1, total, repo_id, + ) + continue + + logger.info( + "[%d/%d] Enriching %s ...", idx + 1, total, repo_id, + ) + t0 = time.perf_counter() + + try: + result = await asyncio.wait_for( + agent.execute_skill( + skill_name=_SKILL_NAME, + input_data={"model_paths": [model_path]}, + progress_callback=None, + ), + timeout=self._per_model_timeout, + ) + + elapsed = time.perf_counter() - t0 + durations[repo_id] = round(elapsed, 2) + + if result.success: + completed_set.add(repo_id) + progress["completed"].append(repo_id) + logger.info( + " ✓ %s (%.1f s) — %s", + repo_id, elapsed, result.summary, + ) + else: + failed_set.add(repo_id) + progress["failed"].append(repo_id) + logger.warning( + " ✗ %s (%.1f s) — %s", + repo_id, elapsed, + "; ".join(result.errors) if result.errors else result.summary, + ) + + results.append({ + "repo_id": repo_id, + "model_path": model_path, + "success": result.success, + "updated_fields": result.updated_models, + "errors": result.errors, + "summary": result.summary, + "duration_s": round(elapsed, 2), + }) + + except asyncio.TimeoutError: + elapsed = time.perf_counter() - t0 + durations[repo_id] = round(elapsed, 2) + timed_out_set.add(repo_id) + progress.setdefault("timed_out", []).append(repo_id) + logger.warning( + " ⏱ TIMEOUT %s (%.1f s, limit=%ds)", + repo_id, elapsed, self._per_model_timeout, + ) + results.append({ + "repo_id": repo_id, + "model_path": model_path, + "success": False, + "errors": [f"Timeout after {self._per_model_timeout}s"], + "summary": "LLM call timed out", + "duration_s": round(elapsed, 2), + }) + + except Exception as exc: + elapsed = time.perf_counter() - t0 + durations[repo_id] = round(elapsed, 2) + failed_set.add(repo_id) + progress["failed"].append(repo_id) + logger.error( + " ✗ %s (%.1f s) — %s", + repo_id, elapsed, exc, + ) + results.append({ + "repo_id": repo_id, + "model_path": model_path, + "success": False, + "errors": [str(exc)], + "summary": f"Exception: {exc}", + "duration_s": round(elapsed, 2), + }) + + # Checkpoint after each model + _save_progress(self._output_dir, progress) + + return { + "results": results, + "progress": progress, + "durations": durations, + } diff --git a/tests/enrich_hf_validation/evaluation_engine.py b/tests/enrich_hf_validation/evaluation_engine.py new file mode 100644 index 00000000..9f3fe153 --- /dev/null +++ b/tests/enrich_hf_validation/evaluation_engine.py @@ -0,0 +1,352 @@ +"""Evaluate enriched ``.metadata.json`` quality across multiple dimensions. + +Scoring rubric (per field): + +- **Completeness**: Is the field populated with meaningful content? +- **Validity**: Does the value conform to expected constraints (controlled + vocab, non-placeholder, parsable JSON)? +- **Accuracy**: (sub-sample only — requires manual verification against + the HF README). +""" + +from __future__ import annotations + +import json +import logging +import os +from typing import Any, Dict, List, Optional, Set + +from .config import ( + CIVITAI_MODEL_TAGS, + PLACEHOLDER_VALUES, + SUPPORTED_BASE_MODELS, +) + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Scoring helpers +# --------------------------------------------------------------------------- + +_MIN_TAGS = 1 +_MAX_TAGS = 8 +_MIN_DESC_LENGTH = 20 +_MIN_NOTES_LENGTH = 30 + +# Tags that the LLM sometimes emits but which are not meaningful content tags. +_TECH_TAGS = frozenset({ + "lora", "dreambooth", "text-to-image", "diffusers", "flux", + "sdxl", "checkpoint", "pytorch", "safetensors", "fine-tuning", + "stable-diffusion", "training", "stablediffusion", +}) + + +def _is_placeholder(val: str) -> bool: + return val.strip().lower() in PLACEHOLDER_VALUES + + +def _is_valid_trigger_words(words: List[str]) -> bool: + """Return True if *words* is a non-empty list of real trigger words.""" + if not words: + return False + cleaned = [w.strip() for w in words if w.strip()] + if not cleaned: + return False + # Reject if ALL entries are placeholders + non_placeholder = [w for w in cleaned if not _is_placeholder(w)] + return len(non_placeholder) > 0 + + +def _is_valid_tags(tags: List[str]) -> bool: + """Return True if *tags* is a reasonable list of content tags.""" + if not tags: + return False + cleaned = [t.strip().lower() for t in tags if t.strip()] + if not cleaned: + return False + # At least one tag that isn't a technical keyword + meaningful = [t for t in cleaned if t not in _TECH_TAGS] + return len(meaningful) >= _MIN_TAGS + + +def _tag_priority_coverage(tags: List[str]) -> float: + """Fraction of tags that align with the user's priority tag vocabulary.""" + if not tags: + return 0.0 + priority_lower = {t.lower() for t in CIVITAI_MODEL_TAGS} + matched = sum(1 for t in tags if t.strip().lower() in priority_lower) + return matched / len(tags) + + +# --------------------------------------------------------------------------- +# Per-model evaluation +# --------------------------------------------------------------------------- + +# Type alias for a score record +ScoreRecord = Dict[str, Any] + + +def evaluate_model( + metadata: Dict[str, Any], + model_path: str, + repo_id: str, + *, + enrichment_success: bool, + enrichment_errors: List[str], +) -> ScoreRecord: + """Score a single enriched model's metadata. + + Returns a dict with per-field scores, a total score, and a list of + flagged issues. + """ + civitai = metadata.get("civitai") or {} + trained_words: List[str] = civitai.get("trainedWords") or metadata.get("trainedWords") or [] + short_desc: str = civitai.get("description") or "" + tags: List[str] = metadata.get("tags") or [] + notes: str = metadata.get("notes") or "" + usage_tips_raw: str = metadata.get("usage_tips") or "{}" + model_description: str = metadata.get("modelDescription") or "" + base_model: str = metadata.get("base_model") or "" + preview_url: str = metadata.get("preview_url") or "" + confidence: str = metadata.get("_llm_confidence") or "" + + # --- base_model --- + base_model_valid = base_model in SUPPORTED_BASE_MODELS + base_model_filled = bool(base_model) and base_model != "Unknown" + + # --- trigger_words (trainedWords) --- + triggers_valid = _is_valid_trigger_words(trained_words) + + # --- short_description (civitai.description) --- + desc_filled = len(short_desc.strip()) >= _MIN_DESC_LENGTH + + # --- tags --- + tags_valid = _is_valid_tags(tags) + tags_priority_coverage = _tag_priority_coverage(tags) + tags_no_technical = ( + sum(1 for t in tags if t.strip().lower() not in _TECH_TAGS) >= _MIN_TAGS + if tags else False + ) + + # --- notes --- + notes_filled = len(notes.strip()) >= _MIN_NOTES_LENGTH + + # --- usage_tips --- + usage_tips_valid = False + if usage_tips_raw.strip() and usage_tips_raw.strip() != "{}": + try: + parsed = json.loads(usage_tips_raw) + if isinstance(parsed, dict) and len(parsed) > 0: + usage_tips_valid = True + except (json.JSONDecodeError, TypeError): + pass + + # --- modelDescription (README → HTML) --- + desc_html_filled = len(model_description.strip()) > 100 + + # --- preview_url --- + preview_filled = bool(preview_url) and os.path.exists(preview_url) + + # ------------------------------------------------------------------ + # Composite score (0-100) + # ------------------------------------------------------------------ + + field_scores = { + "base_model": _score_bool(base_model_filled and base_model_valid, weight=15), + "trigger_words": _score_bool(triggers_valid, weight=15), + "short_description": _score_bool(desc_filled, weight=10), + "tags": _score_bool(tags_valid, weight=15), + "tags_priority_coverage": _score_continuous(tags_priority_coverage, weight=5), + "notes": _score_bool(notes_filled, weight=5), + "usage_tips": _score_bool(usage_tips_valid, weight=5), + "modelDescription_html": _score_bool(desc_html_filled, weight=10), + "preview_downloaded": _score_bool(preview_filled, weight=10), + } + + # Deduct points for enrichment-level failures + penalty = 0 + if enrichment_errors: + penalty += 10 + if not enrichment_success: + penalty += 20 + + total_raw = sum(field_scores.values()) + total = max(0, min(100, total_raw - penalty)) + + # ------------------------------------------------------------------ + # Flagged issues + # ------------------------------------------------------------------ + + issues: List[str] = [] + if not base_model_filled: + issues.append("base_model is empty or 'Unknown'") + elif not base_model_valid: + issues.append(f"base_model '{base_model}' not in SUPPORTED_BASE_MODELS") + if not triggers_valid: + issues.append("trigger_words are missing or contain only placeholders") + if not desc_filled: + issues.append("short_description is too short or empty") + if not tags_valid: + issues.append("tags are missing, too few, or purely technical") + if tags_valid and tags_priority_coverage < 0.5: + issues.append("tags have low overlap with priority_tags (< 50%)") + if not notes_filled: + issues.append("notes are too short or empty") + if not usage_tips_valid: + issues.append("usage_tips is empty or invalid JSON") + if not desc_html_filled: + issues.append("modelDescription is too short (README may not have been converted)") + if not preview_filled: + issues.append("preview image not downloaded (URL missing or download failed)") + + return { + "repo_id": repo_id, + "model_path": model_path, + "enrichment_success": enrichment_success, + "total_score": total, + "field_scores": field_scores, + "issues": issues, + "confidence_from_llm": confidence, + "raw_values": { + "base_model": base_model, + "trigger_words": trained_words, + "short_description": short_desc, + "tags": tags, + "notes": notes, + "usage_tips": usage_tips_raw, + "preview_url": preview_url, + "has_modelDescription": len(model_description) > 0, + }, + } + + +def _score_bool(condition: bool, weight: int = 10) -> int: + return weight if condition else 0 + + +def _score_continuous(value: float, weight: int = 10) -> int: + """Linear interpolation: value 0.0 → 0, value 1.0 → *weight*.""" + return int(round(value * weight)) + + +# --------------------------------------------------------------------------- +# Batch evaluation +# --------------------------------------------------------------------------- + + +def evaluate_batch( + enriched: List[Dict[str, Any]], +) -> List[ScoreRecord]: + """Evaluate a list of enrichment results. + + Each entry in *enriched* should have keys: + ``repo_id``, ``model_path``, ``metadata`` (the enriched dict), + ``success``, ``errors``. + """ + scores: List[ScoreRecord] = [] + for entry in enriched: + record = evaluate_model( + metadata=entry.get("metadata", {}), + model_path=entry.get("model_path", ""), + repo_id=entry.get("repo_id", ""), + enrichment_success=entry.get("success", False), + enrichment_errors=entry.get("errors", []), + ) + scores.append(record) + return scores + + +# --------------------------------------------------------------------------- +# Aggregate statistics +# --------------------------------------------------------------------------- + + +def aggregate_scores(scores: List[ScoreRecord]) -> Dict[str, Any]: + """Compute aggregate stats across all scored models.""" + n = len(scores) + if n == 0: + return {"error": "no scores to aggregate"} + + field_names = [ + "base_model", "trigger_words", "short_description", "tags", + "tags_priority_coverage", "notes", "usage_tips", + "modelDescription_html", "preview_downloaded", + ] + possible = {f: 15 if f == "base_model" or f == "trigger_words" or f == "tags" else + 10 if f == "short_description" or f == "modelDescription_html" or f == "preview_downloaded" else + 5 + for f in field_names} + + # Per-field aggregate + field_agg: Dict[str, Any] = {} + for fn in field_names: + vals = [s["field_scores"].get(fn, 0) for s in scores] + max_per_field = possible[fn] + field_agg[fn] = { + "mean": round(sum(vals) / n, 1) if n else 0, + "fill_rate_pct": round( + sum(1 for v in vals if v >= max_per_field) / n * 100, 1 + ) if n else 0.0, + "partial_rate_pct": round( + sum(1 for v in vals if 0 < v < max_per_field) / n * 100, 1 + ) if n else 0.0, + "empty_rate_pct": round( + sum(1 for v in vals if v == 0) / n * 100, 1 + ) if n else 0.0, + } + + # Total score distribution + total_scores = [s["total_score"] for s in scores] + total_agg = { + "mean": round(sum(total_scores) / n, 1) if n else 0, + "median": _median(total_scores), + "min": min(total_scores) if total_scores else 0, + "max": max(total_scores) if total_scores else 0, + "bins": { + "excellent_80+": sum(1 for s in total_scores if s >= 80), + "good_60_79": sum(1 for s in total_scores if 60 <= s < 80), + "fair_40_59": sum(1 for s in total_scores if 40 <= s < 60), + "poor_20_39": sum(1 for s in total_scores if 20 <= s < 40), + "bad_0_19": sum(1 for s in total_scores if s < 20), + }, + } + + # Issue frequency + issue_counter: Dict[str, int] = {} + for s in scores: + for issue in s["issues"]: + issue_counter[issue] = issue_counter.get(issue, 0) + 1 + top_issues = sorted(issue_counter.items(), key=lambda x: -x[1]) + + # Confidence distribution + conf_counter: Dict[str, int] = {"high": 0, "medium": 0, "low": 0, "": 0} + for s in scores: + c = (s.get("confidence_from_llm") or "").strip().lower() + if c in conf_counter: + conf_counter[c] += 1 + else: + conf_counter[""] += 1 + + # Success / timeout / failure stats + success_count = sum(1 for s in scores if s["enrichment_success"]) + fail_count = n - success_count + + return { + "model_count": n, + "success_count": success_count, + "fail_count": fail_count, + "total_score": total_agg, + "field_aggregates": field_agg, + "top_issues": top_issues[:15], + "confidence_distribution": conf_counter, + } + + +def _median(values: List[float]) -> float: + if not values: + return 0.0 + sorted_v = sorted(values) + m = len(sorted_v) // 2 + if len(sorted_v) % 2 == 0: + return round((sorted_v[m - 1] + sorted_v[m]) / 2, 1) + return round(sorted_v[m], 1) diff --git a/tests/enrich_hf_validation/metadata_constructor.py b/tests/enrich_hf_validation/metadata_constructor.py new file mode 100644 index 00000000..17ac9517 --- /dev/null +++ b/tests/enrich_hf_validation/metadata_constructor.py @@ -0,0 +1,202 @@ +"""Construct initial ``.metadata.json`` sidecars for HF model repos. + +Each HF repo + safetensors pair gets a minimal metadata file — no real model +file is needed. The enrichment pipeline reads only the sidecar. + +Data format (one line per entry):: + + repo_id, model_name.safetensors +""" + +from __future__ import annotations + +import json +import logging +import os +from typing import Any, Dict, List, Tuple + +from .config import CIVITAI_MODEL_TAGS + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Data types +# --------------------------------------------------------------------------- + +# A validated entry parsed from the models file: +# (repo_id, safetensors_name) +RepoEntry = Tuple[str, str] + + +def load_repo_ids(path: str, max_models: int | None = None) -> List[RepoEntry]: + """Read ``repo_id, safetensors_name`` pairs from *path*. + + Format (one per line, blanks and ``#`` comments ignored):: + + user/repo-name, lora_zimage_turbo_myjs_alpha01.safetensors + + Returns a list of ``(repo_id, safetensors_name)`` tuples. + """ + path = os.path.expanduser(path) + if not os.path.exists(path): + raise FileNotFoundError(f"Models file not found: {path}") + + entries: List[RepoEntry] = [] + with open(path, "r", encoding="utf-8") as fh: + for raw_line in fh: + line = raw_line.strip() + if not line or line.startswith("#"): + continue + + # Split on the first comma + if "," not in line: + logger.warning("Skipping malformed line (no comma): %s", raw_line.rstrip()) + continue + + repo_id, safetensors_name = [part.strip() for part in line.split(",", 1)] + if not repo_id or not safetensors_name: + logger.warning("Skipping malformed line (empty fields): %s", raw_line.rstrip()) + continue + if not safetensors_name.lower().endswith(".safetensors"): + logger.warning( + "Skipping line — safetensors_name doesn't end with .safetensors: %s", + raw_line.rstrip(), + ) + continue + + entries.append((repo_id, safetensors_name)) + + if max_models is not None and max_models > 0: + entries = entries[:max_models] + + logger.info("Loaded %d HF repo entries from %s", len(entries), path) + return entries + + +def sanitize_repo_id(repo_id: str) -> str: + """Turn ``user/repo-name`` into a safe directory name.""" + return repo_id.replace("/", "__").replace(".", "_") + + +def build_model_dir(output_dir: str, repo_id: str) -> str: + """Return the per-model working directory.""" + return os.path.join(output_dir, "models", sanitize_repo_id(repo_id)) + + +def build_model_path(model_dir: str, safetensors_name: str) -> str: + """Return the model file path using the real safetensors filename.""" + return os.path.join(model_dir, safetensors_name) + + +def build_metadata_path(model_path: str) -> str: + """Return the sidecar path for a model file. + + This MUST match the convention used by ``MetadataManager`` / + ``apply_metadata_updates``, which derives the sidecar path via + ``os.path.splitext(model_path)[0] + '.metadata.json'``. + For a model file ``lora_x.safetensors`` the sidecar is + ``lora_x.metadata.json`` — *not* ``lora_x.safetensors.metadata.json``. + """ + return f"{os.path.splitext(model_path)[0]}.metadata.json" + + +def create_initial_metadata( + output_dir: str, + repo_id: str, + safetensors_name: str, +) -> str: + """Write a minimal ``.metadata.json`` for *repo_id* + *safetensors_name*. + + Args: + output_dir: Root output directory. + repo_id: HuggingFace repo identifier (``user/repo``). + safetensors_name: The specific model file name (e.g. + ``lora_zimage_turbo_myjs_alpha01.safetensors``). + + Returns the **model path** (the ``.safetensors`` path whose sidecar was + written). The caller passes this path to ``AgentService.execute_skill``. + The basename (filename without extension) will match the real model file, + so ``extract_relevant_section`` can reliably match against the README. + """ + model_dir = build_model_dir(output_dir, repo_id) + os.makedirs(model_dir, exist_ok=True) + model_path = build_model_path(model_dir, safetensors_name) + metadata_path = build_metadata_path(model_path) + + hf_url = f"https://huggingface.co/{repo_id}" + file_name = safetensors_name + + metadata: Dict[str, Any] = { + "file_name": file_name, + "model_name": safetensors_name, + "file_path": model_path.replace(os.sep, "/"), + "size": 0, + "modified": 0, + "sha256": "", + "base_model": "Unknown", + "preview_url": "", + "preview_nsfw_level": 0, + "notes": "", + "from_civitai": False, + "civitai": {}, + "tags": [], + "modelDescription": "", + "civitai_deleted": False, + "favorite": False, + "exclude": False, + "db_checked": False, + "skip_metadata_refresh": False, + "metadata_source": "", + "last_checked_at": 0, + "hash_status": "completed", + "trainedWords": [], + "hf_url": hf_url, + "usage_tips": "{}", + } + + with open(metadata_path, "w", encoding="utf-8") as fh: + json.dump(metadata, fh, indent=2, ensure_ascii=False) + + logger.debug("Created initial metadata for %s -> %s", repo_id, metadata_path) + return model_path + + +def create_all_initial_metadata( + entries: List[RepoEntry], + output_dir: str, + *, + skip_existing: bool = True, +) -> Tuple[List[str], List[str]]: + """Create initial metadata for every repo entry. + + Args: + entries: List of ``(repo_id, safetensors_name)`` tuples. + output_dir: Root output directory. + skip_existing: If True, skip repos whose metadata already exists. + + Returns: + A tuple ``(model_paths, repo_ids)`` — two parallel lists in the same + order as *entries*. This keeps downstream code (enrichment runner, + evaluation engine) unchanged. + """ + model_paths: List[str] = [] + repo_ids: List[str] = [] + for repo_id, safetensors_name in entries: + model_dir = build_model_dir(output_dir, repo_id) + model_path = build_model_path(model_dir, safetensors_name) + metadata_path = build_metadata_path(model_path) + + if skip_existing and os.path.exists(metadata_path): + model_paths.append(model_path) + repo_ids.append(repo_id) + continue + + model_paths.append(create_initial_metadata(output_dir, repo_id, safetensors_name)) + repo_ids.append(repo_id) + + logger.info( + "Constructed initial metadata for %d/%d repos", + len(model_paths), + len(entries), + ) + return model_paths, repo_ids diff --git a/tests/enrich_hf_validation/preprocessing_auditor.py b/tests/enrich_hf_validation/preprocessing_auditor.py new file mode 100644 index 00000000..07e75661 --- /dev/null +++ b/tests/enrich_hf_validation/preprocessing_auditor.py @@ -0,0 +1,467 @@ +"""Preprocessing audit for the HF metadata enrichment validation pipeline. + +Phase 1.5 — runs between Phase 1 (metadata creation) and Phase 2 (enrichment). + +Audits the README preprocessing pipeline (section extraction + cleaning) +for each repo in the dataset, capturing intermediate outputs so we can +distinguish between: + + (A) Preprocessing failed → LLM never saw the right content + (B) Preprocessing succeeded → LLM/prompt needs improvement + +This prevents wasted effort optimizing prompts when the actual problem is +that ``extract_relevant_section`` or ``clean_readme_for_llm`` removed or +misaligned the content the LLM needed. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +import time +from dataclasses import dataclass, field, asdict +from typing import Any, Dict, List, Tuple + +import aiohttp + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Audit record +# --------------------------------------------------------------------------- + + +@dataclass +class AuditRecord: + """Preprocessing audit for a single repo entry.""" + + # Identity + repo_id: str + safetensors_name: str + basename: str # filename without .safetensors + + # Raw README stats + raw_readme_length: int + raw_readme_line_count: int + has_yaml_frontmatter: bool + yaml_has_base_model: bool + yaml_has_tags: bool + + # Section extraction + section_extraction_activated: bool # output < 95% of input length + section_length: int + section_line_count: int + basename_in_section: bool # basename appears in extracted section text + + # Cleaning + cleaned_length: int + cleaned_line_count: int + compression_pct: float # (1 - cleaned/raw) * 100 + + # Widget section (stripped by _strip_widget_section) + widget_section_found: bool + widget_section_length: int + + # Flags (list of anomaly descriptions) + flags: List[str] = field(default_factory=list) + + # Local file path to the saved raw README (for cross-reference) + readme_file: str = "" + + # Staged intermediate output for report detail + raw_readme_preview: str = "" # first 200 chars + section_preview: str = "" # first 300 chars + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_HF_RAW_URL = "https://huggingface.co/{repo_id}/raw/main/README.md" + +# Thresholds for flagging +_SECTION_ACTIVATION_RATIO = 0.95 +_MIN_CLEANED_LENGTH = 100 +_MAX_COMPRESSION_PCT = 99.0 +_MIN_SECTION_LINES = 3 + + +# --------------------------------------------------------------------------- +# Module loader — bypasses parent-package __init__ that imports ComfyUI +# --------------------------------------------------------------------------- + +_readme_processor_module = None + + +def _load_readme_processor(): + """Import ``readme_processor`` without triggering ``folder_paths`` import. + + The normal import path (``py.services.agent.skills.enrich_hf_metadata. + readme_processor``) triggers ``py.services.agent.__init__`` which + imports ``agent_service.py`` → ``py/config.py`` → ComfyUI's + ``folder_paths``, which is not available in standalone mode. + """ + global _readme_processor_module + if _readme_processor_module is not None: + return _readme_processor_module + + import importlib.util + + _RP_PATH = os.path.join( + os.path.dirname(__file__), # tests/enrich_hf_validation/ + "..", "..", + "py", "services", "agent", "skills", "enrich_hf_metadata", + "readme_processor.py", + ) + rp_path = os.path.normpath(_RP_PATH) + if not os.path.exists(rp_path): + logger.error("readme_processor.py not found at %s", rp_path) + return None + + spec = importlib.util.spec_from_file_location( + "readme_processor", rp_path, + ) + if spec is None or spec.loader is None: + logger.error("Could not create spec for readme_processor.py") + return None + + mod = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(mod) + except Exception as exc: + logger.error("Failed to load readme_processor.py: %s", exc) + return None + + _readme_processor_module = mod + return mod + + +# --------------------------------------------------------------------------- +# HF README fetcher +# --------------------------------------------------------------------------- + + +async def _fetch_readme(repo_id: str, session: aiohttp.ClientSession) -> str: + """Fetch the raw README.md from HuggingFace.""" + url = _HF_RAW_URL.format(repo_id=repo_id) + try: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: + if resp.status == 200: + return await resp.text() + logger.warning("Failed to fetch README for %s: HTTP %d", repo_id, resp.status) + return "" + except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + logger.warning("Failed to fetch README for %s: %s", repo_id, exc) + return "" + + +# --------------------------------------------------------------------------- +# Analysis helpers +# --------------------------------------------------------------------------- + + +def _has_yaml_frontmatter(text: str) -> bool: + return bool(text.strip().startswith("---")) + + +def _extract_yaml_field(text: str, field: str) -> bool: + """Check if the given YAML field exists in the frontmatter.""" + lines = text.split("\n") + if not lines or not lines[0].strip().startswith("---"): + return False + end = 1 + while end < len(lines): + if lines[end].strip().startswith("---"): + break + end += 1 + if end >= len(lines): + return False + frontmatter = "\n".join(lines[1:end]) + pattern = rf"^{field}:" + return bool(re.search(pattern, frontmatter, re.MULTILINE)) + + +def _find_widget_section_length(text: str) -> int: + """Find the ``widget:`` YAML section and return its length (0 if none).""" + if not _has_yaml_frontmatter(text): + return 0 + frontmatter_end = text.find("---", 3) + if frontmatter_end == -1: + return 0 + frontmatter = text[3:frontmatter_end] + + # Match widget: through to the next top-level key or frontmatter end + m = re.search(r"\nwidget:", frontmatter) + if not m: + return 0 + # Length from widget: to end of frontmatter (the next \n\w+: or \n---) + return len(frontmatter[m.start():]) + + +# --------------------------------------------------------------------------- +# Core auditor +# --------------------------------------------------------------------------- + + +async def run_audit( + entries: List[Tuple[str, str]], + *, + concurrency: int = 10, + readmes_dir: str | None = None, +) -> Tuple[List[AuditRecord], Dict[str, Any]]: + """Run the preprocessing audit over all repo entries. + + Args: + entries: List of ``(repo_id, safetensors_name)``. + concurrency: Max parallel fetches to HuggingFace. + readmes_dir: If set, saves each fetched README as + ``{sanitized_repo_id}.md`` in this directory for offline + cross-reference against audit results. + + Returns: + Tuple of ``(records, summary)`` where *summary* is a dict with + aggregate statistics. + """ + semaphore = asyncio.Semaphore(concurrency) + records: List[AuditRecord] = [] + flag_counter: Dict[str, int] = {} + + if readmes_dir: + os.makedirs(readmes_dir, exist_ok=True) + + connector = aiohttp.TCPConnector(limit=concurrency) + async with aiohttp.ClientSession(connector=connector) as session: + tasks = [_audit_one(entry, session, semaphore, readmes_dir=readmes_dir) for entry in entries] + gathered = await asyncio.gather(*tasks, return_exceptions=True) + + for entry, result in zip(entries, gathered): + if isinstance(result, Exception): + logger.error("Audit failed for %s: %s", entry[0], result) + records.append( + AuditRecord( + repo_id=entry[0], + safetensors_name=entry[1], + basename=os.path.splitext(entry[1])[0], + raw_readme_length=0, + raw_readme_line_count=0, + has_yaml_frontmatter=False, + yaml_has_base_model=False, + yaml_has_tags=False, + section_extraction_activated=False, + section_length=0, + section_line_count=0, + basename_in_section=False, + cleaned_length=0, + cleaned_line_count=0, + compression_pct=0.0, + widget_section_found=False, + widget_section_length=0, + readme_file="", + flags=[f"Audit exception: {result}"], + ) + ) + continue + + # The continue above ensures result is AuditRecord here + assert isinstance(result, AuditRecord) + records.append(result) + for flag in result.flags: + flag_counter[flag] = flag_counter.get(flag, 0) + 1 + + summary = _build_summary(records, flag_counter) + return records, summary + + +def _sanitize_repo_id(repo_id: str) -> str: + """Turn ``user/repo-name`` into a safe filename.""" + return repo_id.replace("/", "__").replace(".", "_") + + +async def _audit_one( + entry: Tuple[str, str], + session: aiohttp.ClientSession, + semaphore: asyncio.Semaphore, + *, + readmes_dir: str | None = None, +) -> AuditRecord: + """Audit a single repo entry.""" + repo_id, safetensors_name = entry + basename = os.path.splitext(safetensors_name)[0] + + async with semaphore: + # Import production preprocessing functions. + # Use importlib to bypass py.services.agent.__init__ which triggers + # ComfyUI's folder_paths module (not available in standalone mode). + _rp = _load_readme_processor() + if _rp is None: + return AuditRecord( + repo_id=repo_id, + safetensors_name=safetensors_name, + basename=basename, + raw_readme_length=0, raw_readme_line_count=0, + has_yaml_frontmatter=False, yaml_has_base_model=False, yaml_has_tags=False, + readme_file="", + section_extraction_activated=False, section_length=0, section_line_count=0, + basename_in_section=False, cleaned_length=0, cleaned_line_count=0, + compression_pct=0.0, widget_section_found=False, widget_section_length=0, + flags=["IMPORT_FAILED"], + ) + clean_readme_for_llm = _rp.clean_readme_for_llm + extract_relevant_section = _rp.extract_relevant_section + + # Step 1: Fetch the raw README + raw_text = await _fetch_readme(repo_id, session) + if not raw_text: + return AuditRecord( + repo_id=repo_id, + safetensors_name=safetensors_name, + basename=basename, + raw_readme_length=0, + raw_readme_line_count=0, + has_yaml_frontmatter=False, + yaml_has_base_model=False, + yaml_has_tags=False, + section_extraction_activated=False, + section_length=0, + section_line_count=0, + basename_in_section=False, + readme_file="", + cleaned_length=0, + cleaned_line_count=0, + compression_pct=0.0, + widget_section_found=False, + widget_section_length=0, + flags=["README_FETCH_FAILED"], + ) + + # Save the raw README to disk for offline cross-reference + readme_path = "" + if readmes_dir: + safe_name = _sanitize_repo_id(repo_id) + readme_path = os.path.join(readmes_dir, f"{safe_name}.md") + try: + with open(readme_path, "w", encoding="utf-8") as fh: + fh.write(raw_text) + except OSError as exc: + logger.warning("Failed to save README for %s: %s", repo_id, exc) + readme_path = "" + + raw_lines = raw_text.split("\n") + raw_len = len(raw_text) + raw_line_count = len(raw_lines) + + # Step 2: Analyze raw README + yaml_fm = _has_yaml_frontmatter(raw_text) + yaml_has_bm = _extract_yaml_field(raw_text, "base_model") if yaml_fm else False + yaml_has_tg = _extract_yaml_field(raw_text, "tags") if yaml_fm else False + widget_len = _find_widget_section_length(raw_text) + + # Step 3: Section extraction + section = extract_relevant_section(raw_text, basename) + section_len = len(section) + section_line_count = len(section.split("\n")) + section_activated = section_len < raw_len * _SECTION_ACTIVATION_RATIO + basename_in_sec = basename.lower() in section.lower() + + # Step 4: Cleaning for LLM + cleaned = clean_readme_for_llm(section) + cleaned_len = len(cleaned) + cleaned_line_count = len(cleaned.split("\n")) + compression_pct = round((1 - cleaned_len / raw_len) * 100, 1) if raw_len else 0.0 + + # Step 5: Flag anomalies + flags: List[str] = [] + if not raw_text.strip(): + flags.append("README_EMPTY") + if not yaml_fm: + flags.append("NO_YAML_FRONTMATTER") + if not section_activated: + # Check if basename is extremely short/generic (likely synthetic) + if len(basename) <= 5: + flags.append("BASENAME_TOO_SHORT_SECTION_NOT_EXPECTED") + else: + flags.append("SECTION_EXTRACTION_NOT_ACTIVATED") + elif not basename_in_sec: + flags.append("BASENAME_NOT_IN_EXTRACTED_SECTION") + if widget_len == 0: + # Not necessarily a problem — many repos lack a widget section + pass + if cleaned_len < _MIN_CLEANED_LENGTH: + flags.append("CLEANED_README_TOO_SHORT") + if compression_pct > _MAX_COMPRESSION_PCT: + flags.append("EXTREME_COMPRESSION") + if section_activated and section_line_count < _MIN_SECTION_LINES: + flags.append("SECTION_TOO_SMALL") + + return AuditRecord( + repo_id=repo_id, + safetensors_name=safetensors_name, + basename=basename, + raw_readme_length=raw_len, + raw_readme_line_count=raw_line_count, + has_yaml_frontmatter=yaml_fm, + yaml_has_base_model=yaml_has_bm, + yaml_has_tags=yaml_has_tg, + section_extraction_activated=section_activated, + section_length=section_len, + section_line_count=section_line_count, + basename_in_section=basename_in_sec, + cleaned_length=cleaned_len, + cleaned_line_count=cleaned_line_count, + compression_pct=compression_pct, + widget_section_found=widget_len > 0, + widget_section_length=widget_len, + readme_file=readme_path, + flags=flags, + raw_readme_preview=raw_text[:200], + section_preview=section[:300], + ) + + +def _build_summary( + records: List[AuditRecord], + flag_counter: Dict[str, int], +) -> Dict[str, Any]: + """Aggregate audit statistics.""" + n = len(records) + if n == 0: + return {"error": "no records", "model_count": 0} + + activated = sum(1 for r in records if r.section_extraction_activated) + basename_hit = sum(1 for r in records if r.basename_in_section) + with_yaml = sum(1 for r in records if r.has_yaml_frontmatter) + with_widget = sum(1 for r in records if r.widget_section_found) + fetch_failed = sum(1 for r in records if "README_FETCH_FAILED" in r.flags) + + avg_compression = round( + sum(r.compression_pct for r in records if r.raw_readme_length > 0) / max(n - fetch_failed, 1), + 1, + ) + avg_cleaned = round( + sum(r.cleaned_length for r in records if r.raw_readme_length > 0) / max(n - fetch_failed, 1), + ) + + top_flags = sorted(flag_counter.items(), key=lambda x: -x[1])[:10] + + return { + "model_count": n, + "fetch_failed_count": fetch_failed, + "section_extraction_activated": activated, + "section_extraction_pct": round(activated / max(n - fetch_failed, 1) * 100, 1), + "basename_in_section": basename_hit, + "basename_in_section_pct": round(basename_hit / max(n - fetch_failed, 1) * 100, 1), + "with_yaml_frontmatter": with_yaml, + "with_yaml_frontmatter_pct": round(with_yaml / max(n - fetch_failed, 1) * 100, 1), + "with_widget_section": with_widget, + "avg_compression_pct": avg_compression, + "avg_cleaned_length": avg_cleaned, + "top_flags": top_flags, + } + + +def audit_records_to_serializable(records: List[AuditRecord]) -> List[Dict[str, Any]]: + """Convert AuditRecord dataclasses to plain dicts for JSON serialization.""" + return [asdict(r) for r in records] diff --git a/tests/enrich_hf_validation/report_generator.py b/tests/enrich_hf_validation/report_generator.py new file mode 100644 index 00000000..682d1544 --- /dev/null +++ b/tests/enrich_hf_validation/report_generator.py @@ -0,0 +1,391 @@ +"""Generate structured reports from evaluation results. + +Produces: + +1. A JSON data dump (``report.json``) with all scores and aggregations. +2. A human-readable Markdown report (``report.md``) with summary stats, + issue patterns, and actionable optimisation suggestions. +""" + +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime +from typing import Any, Dict, List + +from .config import SUPPORTED_BASE_MODELS +from .evaluation_engine import ScoreRecord + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Markdown report +# --------------------------------------------------------------------------- + + +def _fmt_pct(value: float) -> str: + return f"{value:.1f}%" + + +def _bar(value: float, width: int = 20) -> str: + filled = int(round(value / 100 * width)) + return "█" * filled + "░" * (width - filled) + + +def generate_optimisation_suggestions( + agg: Dict[str, Any], + scores: List[ScoreRecord], +) -> List[str]: + """Analyse evaluation results and produce concrete suggestions.""" + suggestions: List[str] = [] + fa = agg.get("field_aggregates", {}) + + # --- base_model --- + bm = fa.get("base_model", {}) + if bm and bm.get("empty_rate_pct", 0) > 30: + suggestions.append( + "- **base_model 空置率高 ({:.0f}%)**: 多数 HF 模型卡片未在 YAML frontmatter 中声明 " + "`base_model:` 字段,LLM 无法推断。可考虑在 prompt 中增加 \"look at the model file name " + "for clues\" 的引导,或在后处理中增加基于文件名规则的 fallback 猜测。".format( + bm.get("empty_rate_pct", 0) + ) + ) + bm_invalid = sum( + 1 + for s in scores + if s["raw_values"]["base_model"] + and s["raw_values"]["base_model"] != "Unknown" + and s["raw_values"]["base_model"] not in set(SUPPORTED_BASE_MODELS) + ) + if bm_invalid > 5: + suggestions.append( + "- **base_model 含非标准值 ({} 个)**: LLM 输出了未在当前生产系统的 base model 列表 " + "中的名称。建议在 prompt 中强调 \"Use EXACTLY one name from the list\" 并在 " + "`PostProcessor` 中加一层验证过滤,非标准值直接丢弃。".format(bm_invalid) + ) + + # --- trigger_words --- + tw = fa.get("trigger_words", {}) + if tw and tw.get("empty_rate_pct", 0) > 40: + suggestions.append( + "- **trigger_words 空置率高 ({:.0f}%)**: 大量 HF 模型卡没有明确的 " + "`instance_prompt:` 或 trigger word 说明。当前 prompt 已覆盖常见模式。若确认这些模型确实" + "没有 trigger words(例如 style lora),空数组是正确结果,不需优化。".format( + tw.get("empty_rate_pct", 0) + ) + ) + + # --- tags --- + tag = fa.get("tags", {}) + if tag and tag.get("empty_rate_pct", 0) > 30: + suggestions.append( + "- **tags 空置率高 ({:.0f}%)**: 当前 prompt 要求 tags 必须与 " + "`priority_tags`(CIVITAI_MODEL_TAGS)对齐。HF 模型的标签体系与 Civitai 不同," + "很多 model card 使用细粒度标签(如 `pokemon`、`watercolor`)而不在 priority list 中。" + "建议: 扩大 priority_tags 范围,或允许 LLM 自由生成 tags 后只做去重不做严格过滤。".format( + tag.get("empty_rate_pct", 0) + ) + ) + + # --- tags priority coverage --- + low_coverage = sum( + 1 + for s in scores + if s["field_scores"].get("tags_priority_coverage", 5) < 3 # < 60% of max + and s["field_scores"].get("tags", 0) > 0 + ) + if low_coverage > 10: + suggestions.append( + "- **{} 个模型的 tags 与 priority_tags 匹配度低于 60%**: " + "LLM 生成了有意义但不属于 CIVITAI_MODEL_TAGS 的标签。这说明 priority_tags " + "的覆盖范围对 HF 模型不足,建议按 HF 模型的实际分布补充新类别。".format(low_coverage) + ) + + # --- preview --- + prev = fa.get("preview_downloaded", {}) + if prev and prev.get("empty_rate_pct", 0) > 50: + suggestions.append( + "- **预览图下载成功率低 ({:.0f}%)**: 很多 HF 模型卡没有 embed 图片(仅使用 YAML widget " + "或 external link)。当前 `readme_processor.py` 的 `extract_gallery_images` 和 " + "`extract_gallery_table_images` 已覆盖了多数场景。若预览图不重要,可降低此字段权重。".format( + prev.get("empty_rate_pct", 0) + ) + ) + + # --- usage_tips --- + ut = fa.get("usage_tips", {}) + if ut and ut.get("empty_rate_pct", 0) > 70: + suggestions.append( + "- **usage_tips 空置率极高 ({:.0f}%)**: 这是预期行为。HF 模型卡通常不包含 LoRA " + "强度/CLIP skip 等结构化参数。当前提取策略已合理。若需要可用数据," + "可以考虑使用模型类型的通用默认值。".format( + ut.get("empty_rate_pct", 0) + ) + ) + + # --- short_description --- + sd = fa.get("short_description", {}) + if sd and sd.get("empty_rate_pct", 0) > 40: + suggestions.append( + "- **short_description 空置率 ({:.0f}%)**: 部分 HF 模型卡 README 内容极少(仅含标签和训练参数)。".format( + sd.get("empty_rate_pct", 0) + ) + ) + + if not suggestions: + suggestions.append("- 未发现明显问题模式,各字段填充率均在可接受范围。") + + return suggestions + + +def generate_markdown_report( + agg: Dict[str, Any], + scores: List[ScoreRecord], + output_dir: str, + duration_summary: Dict[str, Any] | None = None, + *, + audit_summary: Dict[str, Any] | None = None, + config_warnings: List[str] | None = None, +) -> str: + """Write ``report.md`` and return its content. + + Args: + agg: Aggregate evaluation scores. + scores: Per-model evaluation records. + output_dir: Output directory for the report file. + duration_summary: Optional timing statistics. + audit_summary: Optional preprocessing audit summary (Phase 1.5). + config_warnings: Optional LLM config consistency warnings. + """ + lines: List[str] = [] + def wl(text: str = "") -> None: + lines.append(text) + + wl("# HF Metadata Enrichment Validation Report") + wl() + wl(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + wl(f"Models evaluated: **{agg.get('model_count', 0)}**") + wl(f"Successful enrichments: **{agg.get('success_count', 0)}**") + wl(f"Failures: **{agg.get('fail_count', 0)}**") + wl() + + # ---- Preprocessing Audit Section ---- + if audit_summary and audit_summary.get("model_count", 0) > 0: + wl("## Preprocessing Audit") + wl() + wl(f"| Metric | Value |") + wl(f"|--------|-------|") + wl(f"| Models audited | {audit_summary.get('model_count', 0)} |") + wl(f"| README fetch failed | {audit_summary.get('fetch_failed_count', 0)} |") + wl(f"| Section extraction activated | {_fmt_pct(audit_summary.get('section_extraction_pct', 0))} |") + wl(f"| Basename found in section | {_fmt_pct(audit_summary.get('basename_in_section_pct', 0))} |") + wl(f"| Has YAML frontmatter | {_fmt_pct(audit_summary.get('with_yaml_frontmatter_pct', 0))} |") + wl(f"| Has YAML widget section | {_fmt_pct(audit_summary.get('with_widget_section', 0))} |") + wl(f"| Avg README compression | {audit_summary.get('avg_compression_pct', 0)}% |") + wl(f"| Avg cleaned length | {audit_summary.get('avg_cleaned_length', 0)} chars |") + wl() + + if audit_summary.get("top_flags"): + wl("### Audit Flags (most frequent)") + wl() + for flag, count in audit_summary["top_flags"]: + wl(f"- **{flag}**: {count}x") + wl() + + wl("**Interpretation:**") + wl() + act_pct = audit_summary.get("section_extraction_pct", 0) + if act_pct < 50: + wl( + "- ⚠️ Section extraction activated for fewer than 50% of repos. " + "This may indicate the basename doesn't match README content, or the " + "repos are mostly single-model (where full README is expected)." + ) + else: + wl( + "- ✅ Section extraction is working for most repos — the LLM is " + "receiving focused README sections." + ) + + if audit_summary.get("basename_in_section_pct", 100) < 80: + wl( + "- ⚠️ The safetensors basename was NOT found in the extracted section " + "for many repos. This could mean the section extraction matched the wrong " + "section, or the README doesn't explicitly reference the filename." + ) + wl() + + # ---- Config warnings ---- + if config_warnings: + wl("## ⚠️ Configuration Warnings") + wl() + for w in config_warnings: + wl(f"- {w}") + wl() + + # ---- Duration ---- + if duration_summary: + wl("## Timing") + wl() + wl(f"- Total wall time: **{duration_summary.get('total_wall_s', 0):.0f} s** ") + wl(f" ({duration_summary.get('total_wall_s', 0) / 60:.1f} min)") + wl(f"- Mean per model: **{duration_summary.get('mean_s', 0):.1f} s**") + wl(f"- Median per model: **{duration_summary.get('median_s', 0):.1f} s**") + wl(f"- Fastest: **{duration_summary.get('min_s', 0):.1f} s**") + wl(f"- Slowest: **{duration_summary.get('max_s', 0):.1f} s**") + wl() + + # ---- Overall score ---- + ts = agg.get("total_score", {}) + wl("## Overall Score Distribution (0–100)") + wl() + wl(f"| Metric | Value |") + wl(f"|--------|-------|") + wl(f"| Mean | {ts.get('mean', 'N/A')} |") + wl(f"| Median | {ts.get('median', 'N/A')} |") + wl(f"| Min | {ts.get('min', 'N/A')} |") + wl(f"| Max | {ts.get('max', 'N/A')} |") + wl() + for label, key in [ + ("Excellent (≥80)", "excellent_80+"), + ("Good (60–79)", "good_60_79"), + ("Fair (40–59)", "fair_40_59"), + ("Poor (20–39)", "poor_20_39"), + ("Bad (<20)", "bad_0_19"), + ]: + count = ts.get("bins", {}).get(key, 0) + pct = count / agg["model_count"] * 100 if agg["model_count"] else 0 + wl(f"- **{label}**: {count} models ({_fmt_pct(pct)})") + wl() + + # ---- Per-field aggregates ---- + wl("## Per-Field Completeness") + wl() + wl("| Field | Mean Score | Fill Rate | Empty Rate |") + wl("|-------|-----------:|----------:|-----------:|") + fa = agg.get("field_aggregates", {}) + for fn in [ + "base_model", "trigger_words", "short_description", "tags", + "tags_priority_coverage", "notes", "usage_tips", + "modelDescription_html", "preview_downloaded", + ]: + f = fa.get(fn, {}) + if not f: + continue + wl( + f"| {fn} " + f"| {f.get('mean', 'N/A')} " + f"| {_fmt_pct(f.get('fill_rate_pct', 0))} " + f"| {_fmt_pct(f.get('empty_rate_pct', 0))} |" + ) + wl() + + # ---- Confidence distribution ---- + wl("## LLM Confidence Distribution") + wl() + cd = agg.get("confidence_distribution", {}) + total_conf = sum(cd.values()) or 1 + for level in ["high", "medium", "low", ""]: + count = cd.get(level, 0) + label = level if level else "(not reported)" + pct = count / total_conf * 100 + bar = _bar(pct) + wl(f"- **{label}**: {count} {bar} {_fmt_pct(pct)}") + wl() + + # ---- Top issues ---- + wl("## Most Frequent Issues") + wl() + for issue, count in agg.get("top_issues", []): + pct = count / agg["model_count"] * 100 if agg["model_count"] else 0 + wl(f"- **{issue}** — {count}/{agg['model_count']} ({_fmt_pct(pct)})") + wl() + + # ---- Optimisation suggestions ---- + wl("## Optimisation Suggestions") + wl() + suggestions = generate_optimisation_suggestions(agg, scores) + for s in suggestions: + wl(s) + wl() + + # ---- Per-model detail ---- + wl("## Per-Model Detail") + wl() + wl("
    ") + wl("Click to expand") + wl() + wl("| # | Repo ID | Score | Issues | Confidence |") + wl("|---|---------|------:|--------|------------|") + for i, s in enumerate(scores, 1): + issue_count = len(s["issues"]) + issue_str = ( + f"{issue_count} issue(s)" if issue_count else "✓ ok" + ) + wl( + f"| {i} " + f"| {s['repo_id']} " + f"| {s['total_score']} " + f"| {issue_str} " + f"| {s.get('confidence_from_llm', '') or '-'} |" + ) + wl() + wl("
    ") + wl() + + content = "\n".join(lines) + report_path = os.path.join(output_dir, "report.md") + with open(report_path, "w", encoding="utf-8") as fh: + fh.write(content) + logger.info("Markdown report written to %s", report_path) + return content + + +# --------------------------------------------------------------------------- +# JSON dump +# --------------------------------------------------------------------------- + + +def save_json_report( + agg: Dict[str, Any], + scores: List[ScoreRecord], + enrichment_results: List[Dict[str, Any]], + output_dir: str, + duration_summary: Dict[str, Any] | None = None, + *, + audit_summary: Dict[str, Any] | None = None, + config_warnings: List[str] | None = None, +) -> str: + """Write ``report.json`` and return the path. + + Args: + agg: Aggregate evaluation scores. + scores: Per-model evaluation records. + enrichment_results: Raw enrichment phase results. + output_dir: Output directory. + duration_summary: Optional timing statistics. + audit_summary: Optional preprocessing audit summary. + config_warnings: Optional LLM config consistency warnings. + """ + report: Dict[str, Any] = { + "metadata": { + "generated_at": datetime.now().isoformat(), + "model_count": agg.get("model_count", 0), + }, + "aggregate": agg, + "timing": duration_summary or {}, + "per_model_scores": scores, + "enrichment_results": enrichment_results, + } + if audit_summary: + report["preprocessing_audit"] = audit_summary + if config_warnings: + report["config_warnings"] = config_warnings + + path = os.path.join(output_dir, "report.json") + with open(path, "w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2, ensure_ascii=False) + logger.info("JSON report written to %s", path) + return path diff --git a/tests/enrich_hf_validation/run_validation.py b/tests/enrich_hf_validation/run_validation.py new file mode 100644 index 00000000..11d93da0 --- /dev/null +++ b/tests/enrich_hf_validation/run_validation.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +"""CLI entry point for the HF metadata enrichment validation suite. + +Usage:: + + # Full run (44 models, serial, ~1-2 h) + python -m tests.enrich_hf_validation.run_validation \\ + --output /tmp/hf_enrich_validation + + # Quick smoke test with 2 models + python -m tests.enrich_hf_validation.run_validation --sample 2 + + # Resume from a previous partial run + python -m tests.enrich_hf_validation.run_validation --resume + + # Audit preprocessing only (no LLM calls, fast) + python -m tests.enrich_hf_validation.run_validation --audit-only + + # Custom settings file + python -m tests.enrich_hf_validation.run_validation \\ + --settings /custom/path/settings.json +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import sys +import time +from typing import Any, Dict, List, Tuple + +# Ensure the project root is on sys.path so that ``from py import ...`` works. +_PROJECT_ROOT = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) + +# Add ComfyUI root to sys.path so ``folder_paths`` can be imported. +# Project layout: ComfyUI/custom_nodes/ComfyUI-Lora-Manager/ +_COMFYUI_ROOT = os.path.normpath(os.path.join(_PROJECT_ROOT, "..", "..")) +if _COMFYUI_ROOT not in sys.path: + sys.path.insert(0, _COMFYUI_ROOT) + +from tests.enrich_hf_validation.config import ( + init_supported_base_models, + load_settings, +) +from tests.enrich_hf_validation.metadata_constructor import ( + RepoEntry, + create_all_initial_metadata, + load_repo_ids, +) +from tests.enrich_hf_validation.enrichment_runner import EnrichmentRunner +from tests.enrich_hf_validation.evaluation_engine import ( + aggregate_scores, + evaluate_batch, +) +from tests.enrich_hf_validation.preprocessing_auditor import ( + audit_records_to_serializable, + run_audit, +) +from tests.enrich_hf_validation.report_generator import ( + generate_markdown_report, + save_json_report, +) + +logger = logging.getLogger(__name__) + + +def _setup_logging(verbose: bool) -> None: + level = logging.DEBUG if verbose else logging.INFO + fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" + logging.basicConfig(level=level, format=fmt, stream=sys.stderr) + + # Quiet noisy third-party loggers + for name in ("aiohttp", "asyncio", "urllib3"): + logging.getLogger(name).setLevel(logging.WARNING) + + +def _parse_args(argv: List[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate and optimise HF metadata enrichment via LLM.", + ) + parser.add_argument( + "--models", + default=os.path.join(os.path.dirname(__file__), "test_data", "hf_lora_models_with_safetensors.txt"), + help="Path to the HF repo entries file (format: repo_id, model_name.safetensors per line)", + ) + parser.add_argument( + "--settings", + default="~/.config/ComfyUI-LoRA-Manager/settings.json", + help="Path to LoRA Manager settings.json", + ) + parser.add_argument( + "--output", + default="/tmp/hf_enrich_validation", + help="Output directory for reports and intermediate data", + ) + parser.add_argument( + "--sample", + type=int, + default=0, + help="Process only the first N models (for quick smoke tests)", + ) + parser.add_argument( + "--resume", + action="store_true", + help="Resume from previous partial run (uses progress.json)", + ) + parser.add_argument( + "--no-enrich", + action="store_true", + help="Skip enrichment phase (evaluate existing metadata only)", + ) + parser.add_argument( + "--audit-only", + action="store_true", + help="Run preprocessing audit only (no enrichment, no evaluation)", + ) + parser.add_argument( + "--timeout", + type=int, + default=240, + help="Per-model LLM timeout in seconds (default: 240)", + ) + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="Enable debug logging", + ) + return parser.parse_args(argv) + + +# --------------------------------------------------------------------------- +# Phase helpers +# --------------------------------------------------------------------------- + + +def _phase_header(label: str) -> None: + sep = "=" * 60 + print(f"\n{sep}", file=sys.stderr) + print(f" PHASE: {label}", file=sys.stderr) + print(sep, file=sys.stderr) + + +# --------------------------------------------------------------------------- +# Read back LLM config after enrichment (for consistency reporting) +# --------------------------------------------------------------------------- + + +def _get_actual_llm_config() -> Dict[str, str]: + """Read what LLMService is actually using, if initialized. + + Only meaningful when called AFTER enrichment has started (i.e. after + ``AgentService.get_instance()`` has been called). + """ + try: + from py.services.llm_service import LLMService + + instance = LLMService._instance + if instance is None: + return {"status": "not initialized"} + cfg = instance._get_config() + return { + "provider": cfg.get("provider", ""), + "model": cfg.get("model", ""), + "api_base": cfg.get("api_base", ""), + } + except Exception as exc: + return {"status": f"error: {exc}"} + + +def _compare_llm_config( + pipeline_cfg: Dict[str, Any], + actual_cfg: Dict[str, str], +) -> List[str]: + """Compare pipeline-loaded vs LLMService-used config. + + Returns warning messages if they differ. + """ + warnings: List[str] = [] + if not actual_cfg or actual_cfg.get("status", "") == "not initialized": + warnings.append( + "LLMService was not initialized during this run — cannot verify " + "config consistency." + ) + return warnings + + field_map = [ + ("llm_provider", "provider"), + ("llm_model", "model"), + ("llm_api_base", "api_base"), + ] + for pipeline_key, llm_key in field_map: + pv = (pipeline_cfg.get(pipeline_key) or "").strip() + lv = (actual_cfg.get(llm_key) or "").strip() + if pv and lv and pv != lv: + warnings.append( + f"LLM config mismatch: --settings has '{pv}' for {pipeline_key}, " + f"but LLMService uses '{lv}'. " + f"The pipeline's --settings path ({pipeline_cfg.get('settings_path', '?')}) " + "may differ from where SettingsManager reads." + ) + if not warnings and actual_cfg: + warnings.append( + "✅ LLM config matches between pipeline --settings and LLMService." + ) + return warnings + + +# --------------------------------------------------------------------------- +# Phase 1.5: preprocessing audit +# --------------------------------------------------------------------------- + + +async def _run_preprocessing_audit( + entries: List[RepoEntry], + output_dir: str, +) -> Dict[str, Any]: + """Execute the preprocessing audit and save results.""" + _phase_header("Preprocessing audit") + print(f" Auditing {len(entries)} repos ...", file=sys.stderr) + + readmes_dir = os.path.join(output_dir, "readmes") + t0 = time.perf_counter() + records, summary = await run_audit(entries, readmes_dir=readmes_dir) + elapsed = time.perf_counter() - t0 + + # Save audit data + audit_path = os.path.join(output_dir, "preprocessing_audit.json") + with open(audit_path, "w", encoding="utf-8") as fh: + json.dump( + { + "summary": summary, + "records": audit_records_to_serializable(records), + }, + fh, + indent=2, + ensure_ascii=False, + ) + + print(f" Audit complete: {len(records)} repos in {elapsed:.0f}s", file=sys.stderr) + print(f" Section extraction activated: {summary.get('section_extraction_pct', 0)}%", file=sys.stderr) + print(f" Basename in extracted section: {summary.get('basename_in_section_pct', 0)}%", file=sys.stderr) + print(f" Avg compression: {summary.get('avg_compression_pct', 0)}%", file=sys.stderr) + print(f" Avg cleaned length: {summary.get('avg_cleaned_length', 0)} chars", file=sys.stderr) + print(f" Audit data: {audit_path}", file=sys.stderr) + + if summary.get("top_flags"): + print(" Top flags:", file=sys.stderr) + for flag, count in summary["top_flags"][:5]: + print(f" - {flag}: {count}x", file=sys.stderr) + + return summary + + +async def _run_enrichment( + model_paths: List[str], + repos: List[str], + output_dir: str, + timeout: int, + verbose: bool, +) -> Dict[str, Any]: + """Execute the enrichment phase.""" + runner = EnrichmentRunner( + output_dir=output_dir, + per_model_timeout=timeout, + ) + result = await runner.run(model_paths, repos) + + # Print quick summary + progress = result["progress"] + total_done = ( + len(progress.get("completed", [])) + + len(progress.get("failed", [])) + + len(progress.get("timed_out", [])) + ) + print( + f"\n Enrichment complete: {total_done} processed " + f"({len(progress.get('completed', []))} ok, " + f"{len(progress.get('failed', []))} failed, " + f"{len(progress.get('timed_out', []))} timed out)", + file=sys.stderr, + ) + return result + + +def _collect_enriched_metadata( + model_paths: List[str], + repos: List[str], + results: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Read enriched .metadata.json for each model. + + Uses the same path convention as the rest of the codebase: + ``os.path.splitext(model_path)[0] + '.metadata.json'``. + + Returns a list of dicts with keys: repo_id, model_path, success, + errors, metadata. + """ + enriched: List[Dict[str, Any]] = [] + # Build a lookup from repo_id to enrichment result + result_lookup: Dict[str, Dict[str, Any]] = {} + for r in results: + result_lookup[r["repo_id"]] = r + + for model_path, repo_id in zip(model_paths, repos): + res = result_lookup.get(repo_id, {}) + metadata_path = f"{os.path.splitext(model_path)[0]}.metadata.json" + metadata: Dict[str, Any] = {} + if os.path.exists(metadata_path): + try: + with open(metadata_path, "r", encoding="utf-8") as fh: + metadata = json.load(fh) + except (json.JSONDecodeError, OSError) as exc: + logger.warning("Failed to read %s: %s", metadata_path, exc) + else: + logger.warning( + "Metadata file not found for %s (expected: %s)", + repo_id, metadata_path, + ) + + enriched.append({ + "repo_id": repo_id, + "model_path": model_path, + "success": res.get("success", False), + "errors": res.get("errors", []), + "metadata": metadata, + }) + + return enriched + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +async def main(argv: List[str]) -> int: + args = _parse_args(argv) + _setup_logging(args.verbose) + + output_dir = os.path.abspath(os.path.expanduser(args.output)) + os.makedirs(output_dir, exist_ok=True) + + # ---- Phase 0: Initialise shared state ---- + _phase_header("Initialise") + settings = load_settings(args.settings) + logger.info( + "LLM config from --settings: provider=%s model=%s api_base=%s", + settings["llm_provider"], + settings["llm_model"], + settings["llm_api_base"], + ) + # Load the production base model list (replaces the old hardcoded list) + await init_supported_base_models() + + # ---- Load entries ---- + _phase_header("Load repo entries & construct initial metadata") + entries = load_repo_ids(args.models, max_models=args.sample if args.sample > 0 else None) + model_paths, repo_ids = create_all_initial_metadata( + entries, output_dir, skip_existing=True, + ) + print(f" {len(model_paths)} repos ready", file=sys.stderr) + + # ---- Phase 1.5: Preprocessing audit ---- + audit_summary: Dict[str, Any] = {} + t_start = time.perf_counter() + audit_summary = await _run_preprocessing_audit(entries, output_dir) + + if args.audit_only: + total_wall = time.perf_counter() - t_start + print(f"\n Audit-only done in {total_wall:.0f}s", file=sys.stderr) + print(f" Audit data: {output_dir}/preprocessing_audit.json", file=sys.stderr) + return 0 + + # ---- Phase 2: Enrichment ---- + enrichment_results: List[Dict[str, Any]] = [] + if not args.no_enrich: + _phase_header("Enrich metadata via LLM") + enrichment_out = await _run_enrichment( + model_paths, repo_ids, output_dir, args.timeout, args.verbose, + ) + enrichment_results = enrichment_out["results"] + else: + print(" Enrichment skipped (--no-enrich)", file=sys.stderr) + + t_enrich = time.perf_counter() + + # ---- Phase 3: Evaluation ---- + _phase_header("Evaluate enriched metadata") + enriched = _collect_enriched_metadata(model_paths, repo_ids, enrichment_results) + scores = evaluate_batch(enriched) + agg = aggregate_scores(scores) + print( + f" Mean total score: {agg.get('total_score', {}).get('mean', 'N/A')} / 100", + file=sys.stderr, + ) + print( + f" Models scored: {agg.get('model_count', 0)}", + file=sys.stderr, + ) + + # ---- Phase 4: Report generation ---- + _phase_header("Generate reports") + duration_summary: Dict[str, Any] | None = None + if enrichment_results: + durations = [r.get("duration_s", 0) for r in enrichment_results if r.get("duration_s")] + if durations: + sorted_d = sorted(durations) + m = len(sorted_d) // 2 + duration_summary = { + "total_wall_s": round(t_enrich - t_start, 1), + "mean_s": round(sum(durations) / len(durations), 1), + "median_s": round(sorted_d[m] if len(sorted_d) % 2 else (sorted_d[m - 1] + sorted_d[m]) / 2, 1), + "min_s": round(min(durations), 1), + "max_s": round(max(durations), 1), + } + + # Check LLM config consistency after enrichment (LLMService is now initialized) + actual_llm_cfg = _get_actual_llm_config() + config_warnings = _compare_llm_config(settings, actual_llm_cfg) + + save_json_report( + agg, scores, enrichment_results, output_dir, duration_summary, + audit_summary=audit_summary, config_warnings=config_warnings, + ) + generate_markdown_report( + agg, scores, output_dir, duration_summary, + audit_summary=audit_summary, config_warnings=config_warnings, + ) + + # ---- Final summary ---- + total_wall = time.perf_counter() - t_start + print(f"\n Done in {total_wall:.0f}s ({total_wall / 60:.1f} min)", file=sys.stderr) + print(f" Reports: {output_dir}/report.md, {output_dir}/report.json", file=sys.stderr) + print(file=sys.stderr) + + return 0 if agg.get("success_count", 0) > 0 else 1 + + +def entry_point() -> int: + return asyncio.run(main(sys.argv[1:])) + + +if __name__ == "__main__": + sys.exit(entry_point()) diff --git a/tests/enrich_hf_validation/test_data/base_model_ground_truth.json b/tests/enrich_hf_validation/test_data/base_model_ground_truth.json new file mode 100644 index 00000000..0db1a250 --- /dev/null +++ b/tests/enrich_hf_validation/test_data/base_model_ground_truth.json @@ -0,0 +1,376 @@ +{ + "description": "Ground truth base_model mapping for HF LoRA enrichment test data", + "generated_at": "2026-07-05T18:20:00+08:00", + "inference_method": "Manual analysis of YAML base_model field + README content + filename clues", + "canonical_list_source": "Fallback list in config.py + CivitAI production API (73 models total)", + "entries": [ + { + "repo_id": "k2styles/krea-2-cobalt-sky-anime-lora", + "safetensors_name": "cobalt-sky-anime.safetensors", + "yaml_base_model_raw": "krea/Krea-2-Turbo", + "correct_base_model": "Krea 2", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "k2styles/krea-2-azure-gouache-daylight-lora", + "safetensors_name": "azure-gouache-daylight.safetensors", + "yaml_base_model_raw": "krea/Krea-2-Turbo", + "correct_base_model": "Krea 2", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "TheDivergentAI/krea2-turbo-distill-lora", + "safetensors_name": "krea2_turbo_distill_r128.safetensors", + "yaml_base_model_raw": "krea/Krea-2-Raw", + "correct_base_model": "Krea 2", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "DeverStyle/Krea2-Loras", + "safetensors_name": "n0t_f4l_000001000.safetensors", + "yaml_base_model_raw": "krea/Krea-2-Turbo", + "correct_base_model": "Krea 2", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "Komorebi1995/krea2-raw-jpaf-celpaint-lora", + "safetensors_name": "krea2_raw_jpaf_celpaint_full_v1.safetensors", + "yaml_base_model_raw": null, + "correct_base_model": "Krea 2", + "confidence": "high", + "evidence": "Filename contains 'krea2'" + }, + { + "repo_id": "artificialguybr/pixelartredmond-1-5v-pixel-art-loras-for-sd-1-5", + "safetensors_name": "PixelArtRedmond15V-PixelArt-PIXARFK.safetensors", + "yaml_base_model_raw": "runwayml/stable-diffusion-v1-5", + "correct_base_model": "SD 1.5", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "Shakker-Labs/FLUX.1-dev-LoRA-Logo-Design", + "safetensors_name": "FLUX-dev-lora-Logo-Design.safetensors", + "yaml_base_model_raw": "black-forest-labs/FLUX.1-dev", + "correct_base_model": "Flux.1 D", + "confidence": "high", + "evidence": "YAML base_model: FLUX.1-dev → dev → D" + }, + { + "repo_id": "glif-loradex-trainer/bingbangboom_flux_surf", + "safetensors_name": "flux_surf_000001500.safetensors", + "yaml_base_model_raw": "black-forest-labs/FLUX.1-dev", + "correct_base_model": "Flux.1 D", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "prithivMLmods/Ton618-Epic-Realism-Flux-LoRA", + "safetensors_name": "Epic-Realism-Unpruned.safetensors", + "yaml_base_model_raw": "black-forest-labs/FLUX.1-dev", + "correct_base_model": "Flux.1 D", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "prithivMLmods/Fashion-Hut-Modeling-LoRA", + "safetensors_name": "Fashion-Modeling.safetensors", + "yaml_base_model_raw": "black-forest-labs/FLUX.1-dev", + "correct_base_model": "Flux.1 D", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "prithivMLmods/Retro-Pixel-Flux-LoRA", + "safetensors_name": "Retro-Pixel.safetensors", + "yaml_base_model_raw": "black-forest-labs/FLUX.1-dev", + "correct_base_model": "Flux.1 D", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "D1-3105/HiDream-E1-Full_lora", + "safetensors_name": "HiDream-E1-Full.safetensors", + "yaml_base_model_raw": "HiDream-ai/HiDream-E1-Full", + "correct_base_model": "HiDream", + "confidence": "high", + "evidence": "YAML frontmatter base_model field; filename contains 'HiDream'" + }, + { + "repo_id": "renderartist/Classic-Painting-Z-Image-Turbo-LoRA", + "safetensors_name": "Classic_Painting_Z_Image_Turbo_v1_renderartist_1750.safetensors", + "yaml_base_model_raw": "Tongyi-MAI/Z-Image-Turbo", + "correct_base_model": "ZImageTurbo", + "confidence": "high", + "evidence": "YAML frontmatter base_model field; filename contains 'Z-Image-Turbo'" + }, + { + "repo_id": "DeverStyle/Z-Image-loras", + "safetensors_name": "z_image_archer_style.safetensors", + "yaml_base_model_raw": "Tongyi-MAI/Z-Image-Turbo", + "correct_base_model": "ZImageTurbo", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "deadman44/Z-Image_LoRA", + "safetensors_name": "lora_zimage_turbo_myjs_alpha01.safetensors", + "yaml_base_model_raw": null, + "correct_base_model": "ZImageTurbo", + "confidence": "high", + "evidence": "Filename contains 'zimage_turbo'" + }, + { + "repo_id": "zyuzuguldu/vton-lora-linen", + "safetensors_name": "pytorch_lora_weights.safetensors", + "yaml_base_model_raw": "stabilityai/stable-diffusion-xl-base-1.0", + "correct_base_model": "SDXL 1.0", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "svntax-dev/pixel_spritesheet_4walk_small_lora_v1", + "safetensors_name": "pixel_4walk_small_flux2_klein_base_4b_v1_000002750.safetensors", + "yaml_base_model_raw": "black-forest-labs/FLUX.2-klein-base-4B", + "correct_base_model": "Flux.2 Klein 4B-base", + "confidence": "high", + "evidence": "YAML base_model: FLUX.2-klein-base-4B" + }, + { + "repo_id": "Haruka041/z-image-anime-lora", + "safetensors_name": "sk_anime_style_v1.0.safetensors", + "yaml_base_model_raw": "Tongyi-MAI/Z-Image-Turbo", + "correct_base_model": "ZImageTurbo", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "systms/SYSTMS-INFL8-LoRA-Wan22", + "safetensors_name": "SYSTMS_INFL8_LORA_WAN22_low_noise.safetensors", + "yaml_base_model_raw": "Wan-AI/Wan2.2-I2V-A14B", + "correct_base_model": "Wan Video 2.2 I2V-A14B", + "confidence": "high", + "evidence": "YAML base_model: Wan2.2-I2V-A14B" + }, + { + "repo_id": "crafiq/flux-2-klein-9b-360-panorama-lora", + "safetensors_name": "flux-2-klein-9b-360-panorama-lora.safetensors", + "yaml_base_model_raw": "black-forest-labs/FLUX.2-klein-base-9B", + "correct_base_model": "Flux.2 Klein 9B-base", + "confidence": "high", + "evidence": "YAML base_model: FLUX.2-klein-base-9B; filename contains 'flux-2-klein-9b'" + }, + { + "repo_id": "Leon1000/pixel_spritesheet_4walk_small_lora_v1", + "safetensors_name": "pixel_4walk_small_flux2_klein_base_4b_v1_000002750.safetensors", + "yaml_base_model_raw": "black-forest-labs/FLUX.2-klein-base-4B", + "correct_base_model": "Flux.2 Klein 4B-base", + "confidence": "high", + "evidence": "YAML base_model: FLUX.2-klein-base-4B; filename contains 'flux2_klein_base_4b'" + }, + { + "repo_id": "Muapi/pov-missionary-legs-together-lora", + "safetensors_name": "pov-missionary-legs-together-lora.safetensors", + "yaml_base_model_raw": "OnomaAIResearch/Illustrious-xl-early-release-v0", + "correct_base_model": "Illustrious", + "confidence": "high", + "evidence": "YAML base_model: OnomaAIResearch/Illustrious-*" + }, + { + "repo_id": "ostris/ideogram_4_unconditional_lora", + "safetensors_name": "ideogram_4_unconditional_lora_r16.safetensors", + "yaml_base_model_raw": "ideogram-ai/ideogram-4-fp8", + "correct_base_model": "Ideogram 4.0", + "confidence": "high", + "evidence": "YAML base_model: ideogram-ai/ideogram-4 → Ideogram 4.0; filename contains 'ideogram_4'" + }, + { + "repo_id": "ilkerzgi/krea-2-bleached-surreal-uncanny-lora", + "safetensors_name": "bleached-surreal-uncanny-comfy.safetensors", + "yaml_base_model_raw": "krea/Krea-2-Turbo", + "correct_base_model": "Krea 2", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "ilkerzgi/krea-2-azure-surreal-collage-lora", + "safetensors_name": "azure-surreal-collage-comfy.safetensors", + "yaml_base_model_raw": "krea/Krea-2-Turbo", + "correct_base_model": "Krea 2", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "ilkerzgi/krea-2-airy-gouache-minimalist-lora", + "safetensors_name": "airy-gouache-minimalist-comfy.safetensors", + "yaml_base_model_raw": "krea/Krea-2-Turbo", + "correct_base_model": "Krea 2", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "k2styles/krea-2-airy-watercolor-chibi-lora", + "safetensors_name": "airy-watercolor-chibi.safetensors", + "yaml_base_model_raw": "krea/Krea-2-Turbo", + "correct_base_model": "Krea 2", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "TakeAswing/sdxl-lora-lofi", + "safetensors_name": "pytorch_lora_weights.safetensors", + "yaml_base_model_raw": "stabilityai/stable-diffusion-xl-base-1.0", + "correct_base_model": "SDXL 1.0", + "confidence": "high", + "evidence": "YAML frontmatter base_model field; repo name contains 'sdxl'" + }, + { + "repo_id": "heville/anna-lora-krea2", + "safetensors_name": "pytorch_lora_weights.safetensors", + "yaml_base_model_raw": "krea/Krea-2-Raw", + "correct_base_model": "Krea 2", + "confidence": "high", + "evidence": "YAML frontmatter base_model field; repo name contains 'krea2'" + }, + { + "repo_id": "Brioch/krea2_loras", + "safetensors_name": "mashap_ohwx_woman_krea2.safetensors", + "yaml_base_model_raw": "krea/Krea-2-Raw", + "correct_base_model": "Krea 2", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "hr16/Miwano-Rag-LoRA", + "safetensors_name": "Miwano-Rag-epoch10.lora.safetensors", + "yaml_base_model_raw": null, + "correct_base_model": "SD 1.5", + "confidence": "high", + "evidence": "README: base model is Kanianime (SD 1.5 fine-tune)" + }, + { + "repo_id": "ikuseiso/Personal_Lora_collections", + "safetensors_name": "vergil_devil_may_cry.safetensors", + "yaml_base_model_raw": null, + "correct_base_model": "SD 1.5", + "confidence": "high", + "evidence": "Sample prompt shows Model: AbyssOrangeMix (SD 1.5), 512x768" + }, + { + "repo_id": "Tanger/LoraByTanger", + "safetensors_name": "(v4)layila-000005.safetensors", + "yaml_base_model_raw": null, + "correct_base_model": "SD 1.5", + "confidence": "high", + "evidence": "README: trained on anything4.5 (SD 1.5) and nai (SD 1.5); test images on AbyssOrangeMix2_hard" + }, + { + "repo_id": "DS-Archive/ds-LoRA", + "safetensors_name": "dsharu-v2_lc.safetensors", + "yaml_base_model_raw": null, + "correct_base_model": "SD 1.5", + "confidence": "high", + "evidence": "README explicitly states 'Stable Diffusion 1.5'" + }, + { + "repo_id": "soknife/loras", + "safetensors_name": "irys-regular-subject-more.safetensors", + "yaml_base_model_raw": null, + "correct_base_model": "SD 1.5", + "confidence": "high", + "evidence": "README mentions SD 1.5 fine-tune models (PastelMix, AbyssOrangeMix, Anything)" + }, + { + "repo_id": "prompthero/openjourney-lora", + "safetensors_name": "openjourneyLora.safetensors", + "yaml_base_model_raw": "stabilityai/stable-diffusion-2-1-base", + "correct_base_model": "SD 2.1", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "Banano/banchan-lora", + "safetensors_name": "Bananochan-PonySDXL-v2.safetensors", + "yaml_base_model_raw": null, + "correct_base_model": "Pony", + "confidence": "medium", + "evidence": "Filename contains 'PonySDXL-v2' → Pony base model" + }, + { + "repo_id": "Maisman/No-Game-NoLife-LoRAs", + "safetensors_name": "ShiroNGNL2_Lora.safetensors", + "yaml_base_model_raw": null, + "correct_base_model": "SD 1.5", + "confidence": "high", + "evidence": "Sample prompts show Model: abyssorangemix2_Hardcore (SD 1.5), 512x768" + }, + { + "repo_id": "EarthnDusk/Gambit_Xmen_Anime_Lora_V1.1", + "safetensors_name": "RemyLebeau.safetensors", + "yaml_base_model_raw": null, + "correct_base_model": "SD 1.5", + "confidence": "high", + "evidence": "Trained Feb 2023 via Kohya LoRA (pre-SDXL era), SD 1.5 lineage" + }, + { + "repo_id": "EarthnDusk/DuskfallArt_LoRa", + "safetensors_name": "DuskfallArt.safetensors", + "yaml_base_model_raw": "stable-diffusion-v1-5/stable-diffusion-v1-5", + "correct_base_model": "SD 1.5", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "gaoxiao/pokemon-lora", + "safetensors_name": "pytorch_lora_weights.safetensors", + "yaml_base_model_raw": "runwayml/stable-diffusion-v1-5", + "correct_base_model": "SD 1.5", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "wtcherr/sd-unsplash_10k_canny-model-control-lora", + "safetensors_name": "diffusion_pytorch_model.safetensors", + "yaml_base_model_raw": "runwayml/stable-diffusion-v1-5", + "correct_base_model": "SD 1.5", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "wtcherr/sd-unsplash_10k_blur_rand_KS-model-control-lora", + "safetensors_name": "diffusion_pytorch_model.safetensors", + "yaml_base_model_raw": "runwayml/stable-diffusion-v1-5", + "correct_base_model": "SD 1.5", + "confidence": "high", + "evidence": "YAML frontmatter base_model field" + }, + { + "repo_id": "samurai-architects/lora-starbucks", + "safetensors_name": "starbucks_interior.safetensors", + "yaml_base_model_raw": null, + "correct_base_model": null, + "confidence": "none", + "evidence": "README too minimal, no base_model in YAML, cannot determine" + }, + { + "repo_id": "prithivMLmods/Flux-Long-Toon-LoRA", + "safetensors_name": "Long-Toon.safetensors", + "yaml_base_model_raw": "black-forest-labs/FLUX.1-dev", + "correct_base_model": "Flux.1 D", + "confidence": "high", + "evidence": "YAML base_model: FLUX.1-dev (dev → D)" + }, + { + "repo_id": "Limbicnation/pixel-art-lora", + "safetensors_name": "pytorch_lora_weights.comfyui.safetensors", + "yaml_base_model_raw": "black-forest-labs/FLUX.2-klein-4B", + "correct_base_model": "Flux.2 Klein 4B", + "confidence": "high", + "evidence": "YAML base_model: FLUX.2-klein-4B; README explicitly states base model" + } + ] +} diff --git a/tests/enrich_hf_validation/test_data/hf_lora_models_with_safetensors.txt b/tests/enrich_hf_validation/test_data/hf_lora_models_with_safetensors.txt new file mode 100644 index 00000000..68f7647d --- /dev/null +++ b/tests/enrich_hf_validation/test_data/hf_lora_models_with_safetensors.txt @@ -0,0 +1,46 @@ +k2styles/krea-2-cobalt-sky-anime-lora, cobalt-sky-anime.safetensors +k2styles/krea-2-azure-gouache-daylight-lora, azure-gouache-daylight.safetensors +TheDivergentAI/krea2-turbo-distill-lora, krea2_turbo_distill_r128.safetensors +DeverStyle/Krea2-Loras, n0t_f4l_000001000.safetensors +Komorebi1995/krea2-raw-jpaf-celpaint-lora, krea2_raw_jpaf_celpaint_full_v1.safetensors +artificialguybr/pixelartredmond-1-5v-pixel-art-loras-for-sd-1-5, PixelArtRedmond15V-PixelArt-PIXARFK.safetensors +Shakker-Labs/FLUX.1-dev-LoRA-Logo-Design, FLUX-dev-lora-Logo-Design.safetensors +glif-loradex-trainer/bingbangboom_flux_surf, flux_surf_000001500.safetensors +prithivMLmods/Ton618-Epic-Realism-Flux-LoRA, Epic-Realism-Unpruned.safetensors +prithivMLmods/Fashion-Hut-Modeling-LoRA, Fashion-Modeling.safetensors +prithivMLmods/Retro-Pixel-Flux-LoRA, Retro-Pixel.safetensors +D1-3105/HiDream-E1-Full_lora, HiDream-E1-Full.safetensors +renderartist/Classic-Painting-Z-Image-Turbo-LoRA, Classic_Painting_Z_Image_Turbo_v1_renderartist_1750.safetensors +DeverStyle/Z-Image-loras, z_image_archer_style.safetensors +deadman44/Z-Image_LoRA, lora_zimage_turbo_myjs_alpha01.safetensors +zyuzuguldu/vton-lora-linen, pytorch_lora_weights.safetensors +svntax-dev/pixel_spritesheet_4walk_small_lora_v1, pixel_4walk_small_flux2_klein_base_4b_v1_000002750.safetensors +Haruka041/z-image-anime-lora, sk_anime_style_v1.0.safetensors +systms/SYSTMS-INFL8-LoRA-Wan22, SYSTMS_INFL8_LORA_WAN22_low_noise.safetensors +crafiq/flux-2-klein-9b-360-panorama-lora, flux-2-klein-9b-360-panorama-lora.safetensors +Leon1000/pixel_spritesheet_4walk_small_lora_v1, pixel_4walk_small_flux2_klein_base_4b_v1_000002750.safetensors +Muapi/pov-missionary-legs-together-lora, pov-missionary-legs-together-lora.safetensors +ostris/ideogram_4_unconditional_lora, ideogram_4_unconditional_lora_r16.safetensors +ilkerzgi/krea-2-bleached-surreal-uncanny-lora, bleached-surreal-uncanny-comfy.safetensors +ilkerzgi/krea-2-azure-surreal-collage-lora, azure-surreal-collage-comfy.safetensors +ilkerzgi/krea-2-airy-gouache-minimalist-lora, airy-gouache-minimalist-comfy.safetensors +k2styles/krea-2-airy-watercolor-chibi-lora, airy-watercolor-chibi.safetensors +TakeAswing/sdxl-lora-lofi, pytorch_lora_weights.safetensors +heville/anna-lora-krea2, pytorch_lora_weights.safetensors +Brioch/krea2_loras, mashap_ohwx_woman_krea2.safetensors +hr16/Miwano-Rag-LoRA, Miwano-Rag-epoch10.lora.safetensors +ikuseiso/Personal_Lora_collections, vergil_devil_may_cry.safetensors +Tanger/LoraByTanger, (v4)layila-000005.safetensors +DS-Archive/ds-LoRA, dsharu-v2_lc.safetensors +soknife/loras, irys-regular-subject-more.safetensors +prompthero/openjourney-lora, openjourneyLora.safetensors +Banano/banchan-lora, Bananochan-PonySDXL-v2.safetensors +Maisman/No-Game-NoLife-LoRAs, ShiroNGNL2_Lora.safetensors +EarthnDusk/Gambit_Xmen_Anime_Lora_V1.1, RemyLebeau.safetensors +EarthnDusk/DuskfallArt_LoRa, DuskfallArt.safetensors +gaoxiao/pokemon-lora, pytorch_lora_weights.safetensors +wtcherr/sd-unsplash_10k_canny-model-control-lora, diffusion_pytorch_model.safetensors +wtcherr/sd-unsplash_10k_blur_rand_KS-model-control-lora, diffusion_pytorch_model.safetensors +samurai-architects/lora-starbucks, starbucks_interior.safetensors +prithivMLmods/Flux-Long-Toon-LoRA, Long-Toon.safetensors +Limbicnation/pixel-art-lora, pytorch_lora_weights.comfyui.safetensors diff --git a/tests/metadata_ops/__init__.py b/tests/metadata_ops/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/metadata_ops/test_metadata_ops.py b/tests/metadata_ops/test_metadata_ops.py new file mode 100644 index 00000000..45131103 --- /dev/null +++ b/tests/metadata_ops/test_metadata_ops.py @@ -0,0 +1,1027 @@ +"""Tests for the metadata_ops module (py/metadata_ops/). + +All tests mock the underlying services (scanner, MetadataManager, downloader) +since it is a thin delegation layer. + +Mock targets must match where imports are resolved inside each function +(lazy imports via ``from X import Y`` inside function body). +""" + +from __future__ import annotations + +from unittest import mock + +import pytest + +from py.metadata_ops import ( + list_base_models, + read_metadata, + apply_metadata_updates, + download_preview, + refresh_cache, +) + + +# ====================================================================== +# Helpers +# ====================================================================== + + +class MockCache: + def __init__(self, raw_data: list[dict] | None = None): + self.raw_data = raw_data or [] + + +class MockScanner: + """Simulates a ModelScanner for testing.""" + + def __init__(self, raw_data: list[dict] | None = None): + self._raw_data = raw_data or [] + self.update_single_model_cache = mock.AsyncMock(return_value=True) + + async def get_cached_data(self): + return MockCache(self._raw_data) + + +# ====================================================================== +# list_base_models -- imports ServiceRegistry internally +# ====================================================================== + + +class TestListBaseModels: + + _MOCK_MODELS = ["SDXL 1.0", "Flux.1 D", "SD 1.5"] + + @pytest.mark.asyncio + async def test_returns_all_models(self): + """Verifies the function delegates to CivitaiBaseModelService. + + Uses a monkey-patch on ``get_instance`` to return a controlled mock + so we don't need to work around ``mock.patch``'s dotted-path + limitations with lazy imports inside function bodies.""" + import py.services.civitai_base_model_service as _svc + orig = _svc.CivitaiBaseModelService.get_instance + mock_svc = mock.AsyncMock() + mock_svc.get_base_models.return_value = { + "models": self._MOCK_MODELS, + } + _svc.CivitaiBaseModelService.get_instance = mock.AsyncMock( + return_value=mock_svc, + ) + try: + result = await list_base_models() + assert result == self._MOCK_MODELS + finally: + _svc.CivitaiBaseModelService.get_instance = orig + + @pytest.mark.asyncio + async def test_limit(self): + import py.services.civitai_base_model_service as _svc + orig = _svc.CivitaiBaseModelService.get_instance + mock_svc = mock.AsyncMock() + mock_svc.get_base_models.return_value = {"models": ["A", "B", "C"]} + _svc.CivitaiBaseModelService.get_instance = mock.AsyncMock( + return_value=mock_svc, + ) + try: + result = await list_base_models(limit=2) + assert result == ["A", "B"] + finally: + _svc.CivitaiBaseModelService.get_instance = orig + + @pytest.mark.asyncio + async def test_empty_list_when_service_returns_empty(self): + import py.services.civitai_base_model_service as _svc + orig = _svc.CivitaiBaseModelService.get_instance + mock_svc = mock.AsyncMock() + mock_svc.get_base_models.return_value = {"models": []} + _svc.CivitaiBaseModelService.get_instance = mock.AsyncMock( + return_value=mock_svc, + ) + try: + result = await list_base_models() + assert result == [] + finally: + _svc.CivitaiBaseModelService.get_instance = orig + + @pytest.mark.asyncio + async def test_handles_exception(self): + import py.services.civitai_base_model_service as _svc + orig = _svc.CivitaiBaseModelService.get_instance + mock_svc = mock.AsyncMock() + mock_svc.get_base_models.side_effect = RuntimeError("API error") + _svc.CivitaiBaseModelService.get_instance = mock.AsyncMock( + return_value=mock_svc, + ) + try: + result = await list_base_models() + assert result == [] + finally: + _svc.CivitaiBaseModelService.get_instance = orig + + +# ====================================================================== +# read_metadata -- imports MetadataManager from py.utils.metadata_manager +# ====================================================================== + + +class TestReadMetadata: + + @pytest.mark.asyncio + async def test_delegates_to_metadata_manager(self): + fake = {"file_name": "test", "base_model": "SDXL 1.0"} + with mock.patch("py.utils.metadata_manager.MetadataManager") as mm: + mm.load_metadata_payload = mock.AsyncMock(return_value=fake) + result = await read_metadata("/p.safetensors") + assert result == fake + + @pytest.mark.asyncio + async def test_exception_returns_empty_dict(self): + with mock.patch("py.utils.metadata_manager.MetadataManager") as mm: + mm.load_metadata_payload = mock.AsyncMock(side_effect=ValueError("x")) + result = await read_metadata("/p.safetensors") + assert result == {} + + @pytest.mark.asyncio + async def test_none_coerces_to_empty_dict(self): + with mock.patch("py.utils.metadata_manager.MetadataManager") as mm: + mm.load_metadata_payload = mock.AsyncMock(return_value=None) + result = await read_metadata("/p.safetensors") + assert result == {} + + +# ====================================================================== +# apply_metadata_updates -- uses read_metadata + MetadataManager.save_metadata +# ====================================================================== + + +class TestApplyMetadataUpdates: + + @pytest.mark.asyncio + async def test_updates_field(self): + with ( + mock.patch("py.metadata_ops.read_metadata") as mock_read, + mock.patch("py.utils.metadata_manager.MetadataManager") as mm, + ): + mock_read.return_value = {"base_model": "", "tags": []} + mm.save_metadata = mock.AsyncMock(return_value=True) + updated = await apply_metadata_updates( + "/p.safetensors", {"base_model": "Flux.1 D"} + ) + assert updated == ["base_model"] + mm.save_metadata.assert_awaited_once_with( + "/p.safetensors", {"base_model": "Flux.1 D", "tags": []}, + ) + + @pytest.mark.asyncio + async def test_noop_when_value_unchanged(self): + with ( + mock.patch("py.metadata_ops.read_metadata") as mock_read, + mock.patch("py.utils.metadata_manager.MetadataManager") as mm, + ): + mock_read.return_value = {"base_model": "Flux.1 D"} + updated = await apply_metadata_updates( + "/p.safetensors", {"base_model": "Flux.1 D"} + ) + assert updated == [] + mm.save_metadata.assert_not_called() + + @pytest.mark.asyncio + async def test_multiple_fields(self): + with ( + mock.patch("py.metadata_ops.read_metadata") as mock_read, + mock.patch("py.utils.metadata_manager.MetadataManager") as mm, + ): + mm.save_metadata = mock.AsyncMock(return_value=True) + mock_read.return_value = { + "base_model": "", "modelDescription": "", "tags": [], + } + updated = await apply_metadata_updates( + "/p.safetensors", + {"base_model": "SDXL 1.0", "modelDescription": "A", "tags": ["flux"]}, + ) + assert sorted(updated) == sorted(["base_model", "modelDescription", "tags"]) + saved = mm.save_metadata.call_args[0][1] + assert saved["base_model"] == "SDXL 1.0" + + @pytest.mark.asyncio + async def test_empty_updates_noop(self): + with ( + mock.patch("py.metadata_ops.read_metadata"), + mock.patch("py.utils.metadata_manager.MetadataManager") as mm, + ): + updated = await apply_metadata_updates("/p.safetensors", {}) + assert updated == [] + mm.save_metadata.assert_not_called() + + +# ====================================================================== +# download_preview -- imports get_downloader + ExifUtils +# ====================================================================== + + +class TestDownloadPreview: + + @pytest.mark.asyncio + async def test_empty_url_returns_none(self, tmp_path): + mp = tmp_path / "m.safetensors" + mp.write_bytes(b"fake") + assert await download_preview(str(mp), "") is None + assert await download_preview(str(mp), " ") is None + + @pytest.mark.asyncio + async def test_successful_download_and_optimise(self, tmp_path): + mp = tmp_path / "t.safetensors" + mp.write_bytes(b"fake") + with ( + mock.patch("py.services.downloader.get_downloader") as get_dl, + mock.patch("py.utils.exif_utils.ExifUtils") as exif, + ): + dl = mock.AsyncMock() + dl.download_to_memory = mock.AsyncMock(return_value=(True, b"raw", {})) + get_dl.return_value = dl + exif.optimize_image.return_value = (b"optimized_webp", {}) + result = await download_preview(str(mp), "https://ex.com/i.png") + assert result == str(tmp_path / "t.webp") + assert (tmp_path / "t.webp").exists() + assert (tmp_path / "t.webp").read_bytes() == b"optimized_webp" + + @pytest.mark.asyncio + async def test_download_failure_returns_none(self, tmp_path): + mp = tmp_path / "t.safetensors" + mp.write_bytes(b"fake") + with mock.patch("py.services.downloader.get_downloader") as get_dl: + dl = mock.AsyncMock() + dl.download_to_memory = mock.AsyncMock(return_value=(False, None, {})) + dl.download_file = mock.AsyncMock(return_value=(False, None)) + get_dl.return_value = dl + result = await download_preview(str(mp), "https://ex.com/i.png") + assert result is None + assert not (tmp_path / "t.webp").exists() + + +# ====================================================================== +# refresh_cache -- uses _find_scanner_for_model (ServiceRegistry) +# ====================================================================== + + +class TestRefreshCache: + + @pytest.mark.asyncio + async def test_found_and_refreshed(self): + scanner = MockScanner([{"file_path": "/some/path.safetensors"}]) + with ( + mock.patch( + "py.services.service_registry.ServiceRegistry", + get_lora_scanner=mock.AsyncMock(return_value=scanner), + get_checkpoint_scanner=mock.AsyncMock(return_value=None), + get_embedding_scanner=mock.AsyncMock(return_value=None), + ), + mock.patch("py.metadata_ops.read_metadata") as mock_read, + ): + mock_read.return_value = {"base_model": "SDXL 1.0"} + result = await refresh_cache("/some/path.safetensors") + assert result is True + scanner.update_single_model_cache.assert_awaited_once() + + @pytest.mark.asyncio + async def test_not_found_in_any_scanner(self): + scanner = MockScanner([]) + with mock.patch( + "py.services.service_registry.ServiceRegistry", + get_lora_scanner=mock.AsyncMock(return_value=scanner), + get_checkpoint_scanner=mock.AsyncMock(return_value=None), + get_embedding_scanner=mock.AsyncMock(return_value=None), + ): + result = await refresh_cache("/nonexistent/path.safetensors") + assert result is False + + @pytest.mark.asyncio + async def test_no_metadata_returns_false(self): + scanner = MockScanner([{"file_path": "/some/path.safetensors"}]) + with ( + mock.patch( + "py.services.service_registry.ServiceRegistry", + get_lora_scanner=mock.AsyncMock(return_value=scanner), + get_checkpoint_scanner=mock.AsyncMock(return_value=None), + get_embedding_scanner=mock.AsyncMock(return_value=None), + ), + mock.patch("py.metadata_ops.read_metadata") as mock_read, + ): + mock_read.return_value = {} + result = await refresh_cache("/some/path.safetensors") + assert result is False + + +# ====================================================================== +# convert_readme_to_html — pure function, no mocks needed +# ====================================================================== + + +class TestConvertReadmeToHtml: + """Tests for the inline markdown→HTML converter.""" + + def test_empty_input(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + assert convert_readme_to_html("") == "" + assert convert_readme_to_html(None) == "" # type: ignore[arg-type] + + def test_heading(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + result = convert_readme_to_html("# Title") + assert "

    " in result and "Title" in result + + def test_subheadings(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + md = "## Overview\n\n### Details" + result = convert_readme_to_html(md) + assert "

    Overview

    " in result + assert "

    Details

    " in result + + def test_bold_and_italic(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + md = "**bold** and *italic*" + result = convert_readme_to_html(md) + assert "bold" in result + assert "italic" in result + + def test_inline_code(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + md = "Use `model.train()`" + result = convert_readme_to_html(md) + assert "" in result and "model.train()" in result + + def test_fenced_code_block(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + md = "```python\nprint('hello')\n```" + result = convert_readme_to_html(md) + assert "
    " in result
    +        assert "print" in result and "hello" in result
    +
    +    def test_unordered_list(self):
    +        from py.services.agent.skills.enrich_hf_metadata.readme_processor import \
    +            convert_readme_to_html
    +
    +        md = "- item one\n- item two"
    +        result = convert_readme_to_html(md)
    +        assert "
      " in result + assert "
    • item one
    • " in result + assert "
    • item two
    • " in result + + def test_ordered_list(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + md = "1. first\n2. second" + result = convert_readme_to_html(md) + assert "
        " in result + assert "
      1. first
      2. " in result + assert "
      3. second
      4. " in result + + def test_link(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + md = "[click here](https://example.com)" + result = convert_readme_to_html(md) + assert 'click here' in result + + def test_badge_image_stripped(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + md = "![badge](https://img.shields.io/badge/status-active)" + result = convert_readme_to_html(md) + assert "img.shields.io" not in result + + def test_gallery_stripped(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + md = "Some text\n\nmore text" + result = convert_readme_to_html(md) + assert "Real content

    " in result + + def test_table(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + md = "| A | B |\n|---|---|\n| 1 | 2 |" + result = convert_readme_to_html(md) + assert "" in result + assert "" in result + assert "" in result + + def test_horizontal_rule(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + md = "before\n\n---\n\nafter" + result = convert_readme_to_html(md) + assert "
    " in result + + def test_inline_code_preserves_angle_bracket(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + result = convert_readme_to_html("Use `a < b` in code") + assert "a < b" in result + + def test_blockquote(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + md = "> quoted text" + result = convert_readme_to_html(md) + assert "
    " in result + assert "quoted text" in result + + def test_indented_whitespace_not_treated_as_code(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + convert_readme_to_html + + md = "- item\n \n## heading after spacing" + result = convert_readme_to_html(md) + assert "
    " not in result
    +        assert "

    heading after spacing

    " in result + + +# ====================================================================== +# extract_gallery_images — YAML widget → civitai.images +# ====================================================================== + + +class TestExtractGalleryImages: + + _REPO = "prithivMLmods/Flux-Long-Toon-LoRA" + _README = """--- +tags: +- lora +widget: +- text: "a cat" + output: + url: images/cat.png +- text: >- + multi line + prompt here + output: + url: images/dog.png +base_model: flux +--- +# Content after frontmatter +""" + + def test_extracts_widget_images(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + extract_gallery_images + + images = extract_gallery_images(self._README, self._REPO) + assert len(images) == 2 + + assert images[0]["url"] == ( + "https://huggingface.co/prithivMLmods/Flux-Long-Toon-LoRA" + "/resolve/main/images/cat.png" + ) + assert images[0]["meta"]["prompt"] == "a cat" + assert images[0]["type"] == "image" + assert images[0]["hasMeta"] is True + assert images[0]["hasPositivePrompt"] is True + + assert images[1]["url"] == ( + "https://huggingface.co/prithivMLmods/Flux-Long-Toon-LoRA" + "/resolve/main/images/dog.png" + ) + assert images[1]["meta"]["prompt"] == "multi line prompt here" + + def test_default_dimensions_used(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + extract_gallery_images + + images = extract_gallery_images(self._README, self._REPO) + assert images[0]["width"] == 512 + assert images[0]["height"] == 512 + + def test_custom_dimensions_applied(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + extract_gallery_images + + images = extract_gallery_images( + self._README, self._REPO, + default_width=768, default_height=1024, + ) + assert images[0]["width"] == 768 + assert images[0]["height"] == 1024 + + def test_empty_readme_returns_empty(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + extract_gallery_images + + assert extract_gallery_images("", self._REPO) == [] + assert extract_gallery_images("no frontmatter here", self._REPO) == [] + + def test_empty_repo_returns_empty(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + extract_gallery_images + + assert extract_gallery_images(self._README, "") == [] + + def test_no_widget_returns_empty(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + extract_gallery_images + + md = "---\ntags:\n - lora\n---\n\nContent" + assert extract_gallery_images(md, self._REPO) == [] + + def test_extract_repo_from_hf_url(self): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + extract_repo_from_hf_url + + assert extract_repo_from_hf_url( + "https://huggingface.co/prithivMLmods/Flux-Long-Toon-LoRA" + ) == "prithivMLmods/Flux-Long-Toon-LoRA" + assert extract_repo_from_hf_url("") == "" + assert extract_repo_from_hf_url("not a url") == "" + + def test_plain_yaml_scalar_text(self): + """Unquoted multi-line YAML scalar (plain format) extracts first line only. + The YAML parser only reports the value on the ``text:`` line; continuation + lines are handled by the post-processor from the raw README.""" + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + extract_gallery_images + + md = """--- +widget: +- text: two samurais doing a muay thai fight + while the other leans back. Textured abstract style + output: + url: images/00.png +---""" + images = extract_gallery_images(md, "user/repo") + assert len(images) == 1 + assert images[0]["meta"]["prompt"] == "two samurais doing a muay thai fight" + + +# ====================================================================== +# extract_gallery_table_images — Sample Gallery markdown tables +# ====================================================================== + + +class TestExtractGalleryTableImages: + + _REPO = "Limbicnation/pixel-art-lora" + _README = """## Sample Gallery + +| Preview | Prompt | +|---------|--------| +| ![Knight](./samples/knight.png) | pixel art sprite, a brave knight | +| ![Dragon](./samples/dragon.png) | pixel art sprite, a fire dragon | +""" + + @staticmethod + def _extract(md: str, repo: str = _REPO, existing: set | None = None): + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + extract_gallery_table_images + return extract_gallery_table_images(md, repo, existing_urls=existing) + + def test_extracts_table_images(self): + images = self._extract(self._README) + assert len(images) == 2 + assert "knight.png" in images[0]["url"] + assert images[0]["meta"]["prompt"] == "pixel art sprite, a brave knight" + assert "dragon.png" in images[1]["url"] + + def test_skips_existing_urls(self): + existing = {"https://huggingface.co/Limbicnation/pixel-art-lora/resolve/main/samples/knight.png"} + images = self._extract(self._README, existing=existing) + assert len(images) == 1 + assert "knight.png" not in images[0]["url"] + + def test_empty_readme_returns_empty(self): + assert self._extract("") == [] + + def test_no_gallery_table_returns_empty(self): + md = "## Description\nSome text." + assert self._extract(md) == [] + + def test_non_gallery_table_skipped(self): + md = "| Param | Value |\n|---|---|\n| Steps | 4 |" + assert self._extract(md) == [] + + def test_absolute_url_preserved(self): + md = "| Preview | Prompt |\n|---|---|\n| ![img](https://cdn.example.com/img.png) | text |" + images = self._extract(md, repo="user/repo") + assert len(images) == 1 + assert images[0]["url"] == "https://cdn.example.com/img.png" + + +# ====================================================================== +# clean_readme_for_llm — pre-process README before LLM injection +# ====================================================================== + + +class TestCleanReadmeForLlm: + + @staticmethod + def _clean(md: str, max_length: int = 6000) -> str: + from py.services.agent.skills.enrich_hf_metadata.readme_processor import \ + clean_readme_for_llm + return clean_readme_for_llm(md, max_length=max_length) + + # -- basic guards -------------------------------------------------------- + + def test_none_returns_empty(self): + assert self._clean(None) == "" # type: ignore[arg-type] + + def test_empty_returns_empty(self): + assert self._clean("") == "" + + def test_plain_text_passes_through(self): + result = self._clean("Just some description text.") + assert "Just some description text." in result + + # -- widget section stripping ------------------------------------------- + + def test_widget_stripped_frontmatter_metadata_preserved(self): + """Widget section is stripped, but ``base_model``, ``tags``, + ``instance_prompt`` survive.""" + md = """--- +tags: +- lora +- anime +widget: +- text: "a test prompt" + output: + url: images/test.png +- text: >- + another long + prompt here + output: + url: images/test2.png +base_model: black-forest-labs/FLUX.1-dev +instance_prompt: trigger word +--- +# Model Description +This is the actual content. +""" + result = self._clean(md) + # Widget text stripped (it's handled by the post-processor gallery + # extraction instead) + assert "a test prompt" not in result + assert "another long" not in result + # Non-widget frontmatter preserved + assert "base_model: black-forest-labs/FLUX.1-dev" in result + assert "instance_prompt: trigger word" in result + assert "tags:" in result + assert "- lora" in result + assert "- anime" in result + assert "Model Description" in result + + def test_widget_last_key_in_frontmatter(self): + """Widget stripped, non-widget keys preserved.""" + md = """--- +tags: +- lora +widget: +- output: + url: img.png + text: prompt +--- +# Content +""" + result = self._clean(md) + assert "prompt" not in result + assert "tags:" in result + + def test_no_widget_untouched(self): + md = """--- +tags: +- lora +base_model: flux +--- +# Content +""" + result = self._clean(md) + assert "tags:" in result + assert "base_model: flux" in result + + # -- gallery stripping --------------------------------------------------- + + def test_gallery_tag_stripped(self): + md = "Some text\n\nmore text" + result = self._clean(md) + assert "`` converted to ``![](src)``, preserving URL for LLM.""" + md = '## Preview\n\n\nDescription.' + result = self._clean(md) + assert "![](https://cdn.hf.co/img.webp)" in result + assert "cdn.hf.co" in result # URL preserved for LLM extraction + assert "Description." in result + + def test_inline_image_within_paragraph_preserved(self): + """Inline images inside paragraphs are rare but shouldn't be stripped.""" + md = "Click here ![icon](https://example.com/icon.png) for more info." + result = self._clean(md) + assert "Click here" in result + assert "for more info" in result + + # -- training table stripping -------------------------------------------- + + def test_training_table_stripped(self): + md = """## Training +| Parameter | Value | +|---------------|----------| +| LR Scheduler | constant | +| Optimizer | AdamW | +| Network Dim | 64 | +## Best Dimensions +| Resolution | Status | +|-----------|---------| +| 768x1024 | Best | +""" + result = self._clean(md) + assert "LR Scheduler" not in result + assert "Optimizer" not in result + assert "Network Dim" not in result + # Normal table preserved + assert "Best Dimensions" in result + assert "768x1024" in result + + def test_normal_table_preserved(self): + md = """## Recommended +| Resolution | Status | +|-----------|---------| +| 1024x1024 | Default | +""" + result = self._clean(md) + assert "1024x1024" in result + + # -- boilerplate section stripping --------------------------------------- + + def test_boilerplate_license_stripped(self): + md = """## Description +Some text. +## License +apache-2.0 +Some license details here. +## More Content +After license. +""" + result = self._clean(md) + assert "apache-2.0" not in result + assert "## License" not in result + assert "## Description" in result + assert "## More Content" in result + assert "After license." in result + + def test_boilerplate_disclaimer_stripped(self): + md = """## Description +Some text. +## DISCLAIMER +Legal text here. +## Citation +Bibtex here. +""" + result = self._clean(md) + assert "Legal text" not in result + assert "Bibtex" not in result + assert "Some text." in result + + def test_boilerplate_subsection_not_stripped(self): + """Only top-level (##) boilerplate is stripped; ### subsections inside + non-boilerplate headings are left alone.""" + md = """## Usage +Some text. +### Important Note +This is a note within the usage section. +""" + result = self._clean(md) + assert "Important Note" in result + + # -- massive list stripping ---------------------------------------------- + + def test_massive_name_list_stripped(self): + lines = ["## 2026 Updates:"] + for i in range(12): + lines.append(f"Name{i}A, Name{i}B, Name{i}C, Name{i}D, Name{i}E,") + lines.append("## License") + lines.append("apache") + md = "\n".join(lines) + result = self._clean(md) + assert "Name0A" not in result + assert "Name11E" not in result + assert "## 2026 Updates:" in result + # License stripped by boilerplate + assert "apache" not in result + + def test_short_list_preserved(self): + """Short lists (< 8 consecutive lines) should not be stripped.""" + lines = ["## Tags:"] + for i in range(4): + lines.append(f"tag{i}A, tag{i}B,") + lines.append("## Description") + lines.append("Some text.") + md = "\n".join(lines) + result = self._clean(md) + assert "tag0A" in result + assert "tag3B" in result + + # -- max_length truncation ----------------------------------------------- + + def test_truncation(self): + md = "A" * 100 + "\n" + "B" * 100 + result = self._clean(md, max_length=150) + assert len(result) <= 150 + assert result.startswith("A" * 100) + + # -- integration: end-to-end realistic README ---------------------------- + + def test_realistic_flux_lora_readme(self): + md = """--- +tags: +- text-to-image +- lora +- diffusers +- 3D +- Toon +widget: +- text: >- + Long toons, a close-up of a cartoon character face... + output: + url: images/LT4.png +- text: >- + Long toons, Super Detail, a close-up shot... + output: + url: images/LT5.png +base_model: black-forest-labs/FLUX.1-dev +instance_prompt: Long toons +license: creativeml-openrail-m +--- +# Flux-Long-Toon-LoRA + + + +**The model is still in the training phase.** + +## Model description + +**prithivMLmods/Flux-Long-Toon-LoRA** + +Image Processing Parameters + +| Parameter | Value | Parameter | Value | +|---------------------------|--------|---------------------------|--------| +| LR Scheduler | constant | Noise Offset | 0.03 | +| Optimizer | AdamW | Multires Noise Discount | 0.1 | +| Network Dim | 64 | Multires Noise Iterations | 10 | +| Network Alpha | 32 | Repeat & Steps | 25 & 3270 | +| Epoch | 18 | Save Every N Epochs | 1 | + +## Best Dimensions + +- 768 x 1024 (Best) +- 1024 x 1024 (Default) + +## Setting Up +```python +import torch +from pipelines import DiffusionPipeline + +base_model = "black-forest-labs/FLUX.1-dev" +pipe = DiffusionPipeline.from_pretrained(base_model, torch_dtype=torch.bfloat16) + +lora_repo = "prithivMLmods/Flux-Long-Toon-LoRA" +trigger_word = "Long toons" +pipe.load_lora_weights(lora_repo) +``` + +## Trigger words + +You should use `Long toons` to trigger the image generation. + +## Download model + +Weights for this model are available in Safetensors format. +""" + original_len = len(md) + result = self._clean(md) + + # Significantly smaller: widget + training tables + code blocks + # + boilerplate all stripped + assert len(result) < original_len * 0.35, ( + f"Expected <35% of original, got {len(result)}/{original_len}" + ) + + # Signal preserved + assert "Long toons" in result + assert "black-forest-labs/FLUX.1-dev" in result + assert "3D" in result + assert "Toon" in result + + # Widget content stripped (post-processor handles image extraction) + assert "close-up of a cartoon character face" not in result + + # Noise stripped + assert "import torch" not in result + assert "DiffusionPipeline" not in result + assert "LR Scheduler" not in result + assert "-`` folded block scalar — extract actual content.""" + readme = """--- +widget: +- text: >- + Long toons, a close-up of a cartoon characters face is featured in a + vibrant red backdrop. + output: + url: images/LT4.png +---""" + imgs = R.extract_gallery_images(readme, "user/repo") + assert len(imgs) == 1 + prompt = imgs[0]["meta"]["prompt"] + assert "Long toons" in prompt + assert "vibrant red backdrop" in prompt + assert prompt != ">-" + + def test_widget_dash_prefix_output(self, R): + """YAML ``- output:`` (dash prefix) — regression for widget parsing.""" + readme = """--- +widget: +- output: + url: images/test.png + text: dash test +---""" + imgs = R.extract_gallery_images(readme, "user/repo") + assert len(imgs) == 1 + assert imgs[0]["meta"]["prompt"] == "dash test" + assert "images/test.png" in imgs[0]["url"] + + def test_widget_mixed_entries(self, R): + """Multiple widget entries with different text styles.""" + readme = """--- +widget: +- text: >- + First entry description. + output: + url: img1.png +- text: second entry + output: + url: img2.png +- text: 'third entry' + output: + url: img3.png +---""" + imgs = R.extract_gallery_images(readme, "user/repo") + assert len(imgs) == 3 + assert imgs[0]["meta"]["prompt"] == "First entry description." + assert imgs[1]["meta"]["prompt"] == "second entry" + assert imgs[2]["meta"]["prompt"] == "third entry" + + +# ====================================================================== +# extract_simple_markdown_images +# ====================================================================== + + +class TestExtractSimpleMarkdownImages: + def test_empty(self, R): + assert R.extract_simple_markdown_images("", "repo") == [] + + def test_basic_markdown_image(self, R): + """``![alt](./img.png)`` → absolute URL.""" + imgs = R.extract_simple_markdown_images("![test](./image_0.png)", "u/r") + assert len(imgs) == 1 + assert "image_0.png" in imgs[0]["url"] + assert imgs[0]["meta"]["prompt"] == "test" + + def test_absolute_url(self, R): + """``![alt](https://...)`` → keep as-is.""" + imgs = R.extract_simple_markdown_images( + "![img](https://example.com/img.png)", "u/r" + ) + assert len(imgs) == 1 + assert imgs[0]["url"] == "https://example.com/img.png" + + def test_skips_code_fences(self, R): + """Inside ``` blocks should be ignored.""" + text = """outside +``` +![inside](./img.png) +``` +outside again +![valid](./valid.png)""" + imgs = R.extract_simple_markdown_images(text, "u/r") + assert len(imgs) == 1 + assert "valid.png" in imgs[0]["url"] + + def test_deduplicates(self, R): + text = "![a](./img.png)\n![b](./img.png)" + imgs = R.extract_simple_markdown_images(text, "u/r") + assert len(imgs) == 1 # deduplicated + + +# ====================================================================== +# extract_html_img_tags +# ====================================================================== + + +class TestExtractHtmlImgTags: + def test_double_quoted_src(self, R): + imgs = R.extract_html_img_tags('', "u/r") + assert len(imgs) == 1 + assert "img.png" in imgs[0]["url"] + + def test_single_quoted_src(self, R): + imgs = R.extract_html_img_tags("", "u/r") + assert len(imgs) == 1 + assert "img.png" in imgs[0]["url"] + + def test_absolute_url(self, R): + imgs = R.extract_html_img_tags( + '', "u/r" + ) + assert len(imgs) == 1 + assert imgs[0]["url"] == "https://cdn.example.com/img.png" + + def test_deduplicates_across_formats(self, R): + text = '\n' + imgs = R.extract_html_img_tags(text, "u/r") + assert len(imgs) == 1 + + +# ====================================================================== +# extract_gallery_table_images +# ====================================================================== + + +class TestExtractGalleryTableImages: + def test_gallery_table(self, R): + text = """| Preview | Prompt | +|--------|--------| +| ![img](./a.png) | a cat | +| ![img](./b.png) | a dog |""" + imgs = R.extract_gallery_table_images(text, "u/r") + assert len(imgs) == 2 + assert imgs[0]["meta"]["prompt"] == "a cat" + assert "a.png" in imgs[0]["url"] + assert imgs[1]["meta"]["prompt"] == "a dog" + + def test_skips_non_gallery_table(self, R): + text = """| Parameter | Value | +|----------|-------| +| Steps | 4 |""" + imgs = R.extract_gallery_table_images(text, "u/r") + assert len(imgs) == 0 + + +# ====================================================================== +# clean_readme_for_llm + strip helpers +# ====================================================================== + + +class TestCleanReadmeForLlm: + def test_preserves_plain_code_block(self, R): + """`` ``` `` without language tag → preserved (trigger words).""" + text = """Before +``` +pixel art sprite, game asset +``` +After""" + cleaned = R.clean_readme_for_llm(text) + assert "pixel art sprite" in cleaned + assert "game asset" in cleaned + + def test_strips_fenced_code_with_lang(self, R): + """`` ```python `` → stripped.""" + text = "before\n```python\nimport torch\n```\nafter" + cleaned = R.clean_readme_for_llm(text) + assert "import torch" not in cleaned + assert "before" in cleaned + assert "after" in cleaned + + def test_preserves_markdown_image_url(self, R): + """``![alt](url)`` → URL kept for LLM preview extraction.""" + text = "![sample](./preview.png)" + cleaned = R.clean_readme_for_llm(text) + assert "./preview.png" in cleaned + + def test_converts_html_img_tag_to_markdown_image(self, R): + """```` → ``![](src)`` preserving URL for LLM.""" + text = 'before\n\nafter' + cleaned = R.clean_readme_for_llm(text) + assert "![](logo.png)" in cleaned + assert "logo.png" in cleaned # URL preserved for LLM extraction + + def test_widget_stripped_frontmatter_preserved(self, R): + """Widget YAML stripped but ``base_model:`` kept.""" + text = """--- +tags: [test] +widget: +- text: >- + long description here + output: + url: img.png +base_model: black-forest-labs/FLUX.1-dev +instance_prompt: test +---""" + cleaned = R.clean_readme_for_llm(text) + assert "widget:" not in cleaned + assert "black-forest-labs/FLUX.1-dev" in cleaned + assert "instance_prompt: test" in cleaned + + def test_training_table_stripped(self, R): + """Training-parameter table → stripped.""" + text = """before +| LR Scheduler | constant | +|--------------|---------| +| Optimizer | AdamW | +after""" + cleaned = R.clean_readme_for_llm(text) + assert "LR Scheduler" not in cleaned + assert "Optimizer" not in cleaned + assert "before" in cleaned + assert "after" in cleaned + + def test_best_dimensions_table_kept(self, R): + """Non-training table (Best Dimensions) → kept.""" + text = """## Best Dimensions +- 768 x 1024 (Best) +- 1024 x 1024 (Default)""" + cleaned = R.clean_readme_for_llm(text) + assert "768 x 1024" in cleaned + + def test_boilerplate_section_stripped(self, R): + text = """stuff +## Download model +[link](url) +## Next section +content""" + cleaned = R.clean_readme_for_llm(text) + assert "Download model" not in cleaned + assert "Next section" in cleaned + assert "content" in cleaned + + def test_returns_empty_for_none(self, R): + assert R.clean_readme_for_llm(None) == "" + + def test_returns_empty_for_empty(self, R): + assert R.clean_readme_for_llm("") == "" + + +# ====================================================================== +# _is_heading / _heading_level +# ====================================================================== + + +class TestHeadingDetection: + @pytest.mark.parametrize( + "line,expected", + [ + ("# Title", 1), + ("## Sub", 2), + ("### Subsub", 3), + ("#### Subsubsub", 4), + ("

    Title

    ", 1), + ("

    Sub

    ", 2), + ("

    Sub

    ", 3), + ("

    Sub

    ", 4), + ("not a heading", 0), + ("###", 0), # no text after ### + ("", 0), # closing tag, not a heading + ("", 0), + ], + ) + def test_heading_level(self, R, line, expected): + assert R._heading_level(line) == expected + + @pytest.mark.parametrize( + "line,expected", + [ + ("# Title", True), + ("

    Sub

    ", True), + ("", False), # closing tag + ("not heading", False), + ], + ) + def test_is_heading(self, R, line, expected): + assert R._is_heading(line) == expected + + +# ====================================================================== +# extract_relevant_section +# ====================================================================== + + +class TestExtractRelevantSection: + def test_fallback_full_readme(self, R): + """No match → full README returned.""" + readme = "# Title\n\nsome content" + assert R.extract_relevant_section(readme, "nonexistent") == readme + + def test_empty_basename_returns_full(self, R): + readme = "# Title" + assert R.extract_relevant_section(readme, "") == readme + + def test_match_heading_includes_yaml(self, R): + """Matching heading should still include YAML frontmatter.""" + readme = """--- +base_model: foo +--- +# My-Model-Title + +content +## Subsection +more""" + section = R.extract_relevant_section(readme, "My-Model") + assert "base_model: foo" in section + assert "content" in section + assert "Subsection" in section + + def test_match_heading_includes_subheadings(self, R): + """``# Title`` match includes all ``##`` children.""" + readme = """# Main Title + +## Child A +content A +## Child B +content B +## Child C +content C""" + section = R.extract_relevant_section(readme, "Main Title") + assert "Child A" in section + assert "Child B" in section + assert "Child C" in section + + def test_match_download_link(self, R): + """Download link containing basename → section extracted.""" + readme = """# Collection +## Model A +[Download](./model_a.safetensors) +## MyModel +[Download](./mymodel.safetensors) +content here +## Model B +other""" + section = R.extract_relevant_section(readme, "mymodel") + assert "content here" in section + assert "Model A" not in section # should not include sibling + + def test_heading_closing_tag_not_boundary(self, R): + """```` should NOT be treated as a section boundary.""" + readme = """# Title +

    some text

    + +## Real Section +content""" + section = R.extract_relevant_section(readme, "Title") + assert "Real Section" in section # forward walk should not stop at + assert "content" in section + + +# ====================================================================== +# _extract_frontmatter +# ====================================================================== + + +class TestExtractFrontmatter: + def test_basic(self, R): + assert R._extract_frontmatter("---\ntags: [a]\n---\nbody") == "\ntags: [a]\n" + + def test_no_frontmatter(self, R): + assert R._extract_frontmatter("no dashes") == "" + + def test_empty_string(self, R): + assert R._extract_frontmatter("") == "" + + +# ====================================================================== +# _strip_widget_section +# ====================================================================== + + +class TestStripWidgetSection: + def test_strip_widget_keep_base_model(self, R): + """Widget stripped but ``base_model:`` preserved.""" + text = """--- +tags: [test] +widget: +- text: >- + long text + output: + url: img.png +base_model: black-forest-labs/FLUX.1-dev +---""" + result = R._strip_widget_section(text) + assert "widget:" not in result + assert "black-forest-labs/FLUX.1-dev" in result + + def test_no_widget_no_change(self, R): + text = "---\ntags: [a]\n---" + assert R._strip_widget_section(text) == text + + def test_widget_at_end_of_frontmatter(self, R): + """Widget is the last YAML key before closing ---.""" + text = """--- +base_model: a +widget: +- text: x + output: + url: y.png +---""" + result = R._strip_widget_section(text) + assert "widget:" not in result + assert "base_model: a" in result + + +# ====================================================================== +# _strip_fenced_code_blocks +# ====================================================================== + + +class TestStripFencedCodeBlocks: + def test_strips_with_language(self, R): + text = "a\n```python\ncode\n```\nb" + assert R._strip_fenced_code_blocks(text) == "a\nb" + + def test_keeps_plain_fence(self, R): + """`` ``` `` without language → preserved.""" + text = "a\n```\ntrigger words\n```\nb" + assert "trigger words" in R._strip_fenced_code_blocks(text) + + def test_pattern(self, R): + text = "x\n```yaml\nkey: val\n```\ny" + assert "key: val" not in R._strip_fenced_code_blocks(text) diff --git a/tests/routes/__snapshots__/test_api_snapshots.ambr b/tests/routes/__snapshots__/test_api_snapshots.ambr index bb01cca6..c2ee524b 100644 --- a/tests/routes/__snapshots__/test_api_snapshots.ambr +++ b/tests/routes/__snapshots__/test_api_snapshots.ambr @@ -28,6 +28,7 @@ 'settings': dict({ 'civitai_api_key_set': True, 'language': 'en', + 'llm_api_key_set': False, 'theme': 'dark', }), 'success': True, diff --git a/tests/services/test_llm_service.py b/tests/services/test_llm_service.py new file mode 100644 index 00000000..5fb1a5e1 --- /dev/null +++ b/tests/services/test_llm_service.py @@ -0,0 +1,263 @@ +"""Tests for the LLMService.""" + +from __future__ import annotations + +import asyncio +import json +from unittest import mock + +import pytest + +from py.services.llm_service import LLMService +from py.services.errors import LLMNotConfiguredError, LLMRateLimitError, LLMResponseError + + +class MockSettings: + """Minimal settings mock for LLMService tests.""" + + def __init__(self, **kwargs): + self._data = { + "llm_enabled": False, + "llm_provider": "openai", + "llm_api_key": "", + "llm_api_base": "", + "llm_model": "", + } + self._data.update(kwargs) + + def get(self, key, default=None): + return self._data.get(key, default) + + +class MockResponse: + """Mock aiohttp response.""" + + def __init__(self, status, json_data=None, text_data="", headers=None): + self.status = status + self._json_data = json_data + self._text_data = text_data + self.headers = headers or {} + + async def json(self): + return self._json_data + + async def text(self): + return self._text_data + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + +class MockSession: + """Mock aiohttp ClientSession.""" + + def __init__(self, response): + self._response = response + self.closed = False + + def post(self, url, json=None, headers=None): + self.last_url = url + self.last_json = json + self.last_headers = headers + return self._response + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + +@pytest.fixture +def llm_service(): + """Create an LLMService with mock settings.""" + LLMService.reset_instance() + settings = MockSettings( + llm_enabled=True, + llm_provider="openai", + llm_api_key="sk-test-key", + llm_api_base="", + llm_model="gpt-4o-mini", + ) + return LLMService(settings) + + +class TestLLMServiceConfiguration: + def test_is_configured_when_enabled_with_key_and_model(self, llm_service): + assert llm_service.is_configured() is True + + def test_not_configured_when_disabled(self): + settings = MockSettings( + llm_enabled=False, llm_api_key="sk-test", llm_model="gpt-4o" + ) + service = LLMService(settings) + # Lenient: model + API key is treated as configured even without + # the toggle, because the user clearly intends to use the feature. + assert service.is_configured() is True + + def test_not_configured_without_model(self): + settings = MockSettings(llm_enabled=True, llm_api_key="sk-test", llm_model="") + service = LLMService(settings) + assert service.is_configured() is False + + def test_not_configured_without_api_key_for_openai(self): + settings = MockSettings(llm_enabled=True, llm_api_key="", llm_model="gpt-4o") + service = LLMService(settings) + assert service.is_configured() is False + + def test_ollama_configured_without_api_key(self): + settings = MockSettings( + llm_enabled=True, llm_provider="ollama", llm_api_key="", llm_model="llama3" + ) + service = LLMService(settings) + assert service.is_configured() is True + + def test_resolve_api_base_openai_default(self, llm_service): + assert llm_service._resolve_api_base("openai", "") == "https://api.openai.com/v1" + + def test_resolve_api_base_ollama_default(self, llm_service): + assert llm_service._resolve_api_base("ollama", "") == "http://localhost:11434/v1" + + def test_resolve_api_base_custom_override(self, llm_service): + assert llm_service._resolve_api_base("custom", "https://my.api.com/v1/") == "https://my.api.com/v1" + + def test_ensure_configured_raises_when_disabled(self): + settings = MockSettings(llm_enabled=False) + service = LLMService(settings) + with pytest.raises(LLMNotConfiguredError): + service._ensure_configured() + + def test_ensure_configured_raises_without_model(self): + settings = MockSettings(llm_enabled=True, llm_api_key="sk-test", llm_model="") + service = LLMService(settings) + with pytest.raises(LLMNotConfiguredError): + service._ensure_configured() + + def test_not_configured_custom_without_api_base(self): + settings = MockSettings( + llm_enabled=True, llm_provider="custom", + llm_api_key="sk-test", llm_api_base="", llm_model="gpt-4o", + ) + service = LLMService(settings) + assert service.is_configured() is False + + def test_custom_configured_with_api_base(self): + settings = MockSettings( + llm_enabled=True, llm_provider="custom", + llm_api_key="sk-test", + llm_api_base="https://my.api.com/v1", llm_model="gpt-4o", + ) + service = LLMService(settings) + assert service.is_configured() is True + + def test_ensure_configured_raises_custom_without_api_base(self): + settings = MockSettings( + llm_enabled=True, llm_provider="custom", + llm_api_key="sk-test", llm_api_base="", llm_model="gpt-4o", + ) + service = LLMService(settings) + with pytest.raises(LLMNotConfiguredError, match="API base URL"): + service._ensure_configured() + + +class TestLLMServiceChatCompletion: + @pytest.mark.asyncio + async def test_chat_completion_success(self, llm_service): + mock_response = MockResponse( + 200, + json_data={ + "choices": [{"message": {"content": "Hello!"}}], + "usage": {"total_tokens": 10}, + "model": "gpt-4o-mini", + }, + ) + mock_session = MockSession(mock_response) + + with mock.patch("aiohttp.ClientSession", return_value=mock_session): + result = await llm_service.chat_completion( + messages=[{"role": "user", "content": "Hi"}], + ) + + assert result["content"] == "Hello!" + assert result["usage"]["total_tokens"] == 10 + assert result["model"] == "gpt-4o-mini" + + @pytest.mark.asyncio + async def test_chat_completion_raises_on_not_configured(self): + settings = MockSettings(llm_enabled=False) + service = LLMService(settings) + with pytest.raises(LLMNotConfiguredError): + await service.chat_completion(messages=[]) + + @pytest.mark.asyncio + async def test_chat_completion_raises_on_http_error(self, llm_service): + mock_response = MockResponse(500, text_data="Internal Server Error") + mock_session = MockSession(mock_response) + + with mock.patch("aiohttp.ClientSession", return_value=mock_session): + with pytest.raises(LLMResponseError, match="HTTP 500"): + await llm_service.chat_completion(messages=[]) + + @pytest.mark.asyncio + async def test_chat_completion_raises_on_rate_limit(self, llm_service): + mock_response = MockResponse(429, text_data="Rate limited", headers={"Retry-After": "0"}) + mock_session = MockSession(mock_response) + + with mock.patch("aiohttp.ClientSession", return_value=mock_session): + with pytest.raises(LLMRateLimitError): + await llm_service.chat_completion( + messages=[], retry_on_rate_limit=False + ) + + @pytest.mark.asyncio + async def test_chat_completion_raises_on_bad_response_structure(self, llm_service): + mock_response = MockResponse(200, json_data={"unexpected": "data"}) + mock_session = MockSession(mock_response) + + with mock.patch("aiohttp.ClientSession", return_value=mock_session): + with pytest.raises(LLMResponseError, match="Unexpected LLM response"): + await llm_service.chat_completion(messages=[]) + + +class TestLLMServiceChatCompletionJson: + @pytest.mark.asyncio + async def test_chat_completion_json_parses_json(self, llm_service): + mock_response = MockResponse( + 200, + json_data={ + "choices": [{"message": {"content": '{"key": "value"}'}}], + "usage": {}, + "model": "gpt-4o-mini", + }, + ) + mock_session = MockSession(mock_response) + + with mock.patch("aiohttp.ClientSession", return_value=mock_session): + result = await llm_service.chat_completion_json( + system_prompt="You are helpful.", + user_prompt="Return JSON.", + ) + + assert result == {"key": "value"} + + @pytest.mark.asyncio + async def test_chat_completion_json_raises_on_non_json(self, llm_service): + # Non-JSON content raises LLMResponseError (salvage also fails) + mock_response = MockResponse( + 200, + json_data={ + "choices": [{"message": {"content": "not json at all"}}], + "usage": {}, + }, + ) + mock_session = MockSession(mock_response) + + with mock.patch("aiohttp.ClientSession", return_value=mock_session): + with pytest.raises(LLMResponseError, match="could not be parsed as JSON"): + await llm_service.chat_completion_json( + system_prompt="test", + user_prompt="test", + ) diff --git a/tests/services/test_post_processor.py b/tests/services/test_post_processor.py new file mode 100644 index 00000000..3779fe30 --- /dev/null +++ b/tests/services/test_post_processor.py @@ -0,0 +1,434 @@ +"""Tests for the PostProcessor (py/services/agent/post_processor.py). + +PostProcessor delegates all I/O to AgentCLI — these tests mock AgentCLI +functions and verify the business logic (conditions, merges, dispatch). +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest import mock + +import pytest + +from py.services.agent.post_processor import PostProcessor + + +@pytest.fixture +def processor(): + return PostProcessor() + + +# ====================================================================== +# process() — routing +# ====================================================================== + + +class TestProcessDispatch: + @pytest.mark.asyncio + async def test_unknown_skill_returns_error(self, processor): + result = await processor.process( + skill_name="nonexistent", + model_path="/p.safetensors", + llm_output={}, + metadata={}, + ) + assert result["success"] is False + assert "nonexistent" in result["errors"][0] + + @pytest.mark.asyncio + async def test_enrich_hf_metadata_routes_correctly(self, processor): + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview") as mock_dl, + mock.patch("py.metadata_ops.refresh_cache") as mock_ref, + ): + mock_apply.return_value = ["metadata_source"] + mock_dl.return_value = None + + result = await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output={}, + metadata={"from_civitai": True}, + ) + + assert result["success"] is True + + +# ====================================================================== +# enrich_hf_metadata — field-level logic +# ====================================================================== + + +class TestEnrichHfMetadata: + """Business logic tests for the enrich_hf_metadata post-processor.""" + + MIN_LLM_OUTPUT = { + "base_model": "", + "trigger_words": [], + "short_description": "", + "tags": [], + "recommended_width": 0, + "recommended_height": 0, + "preview_url": "", + "confidence": "low", + } + + # -- base_model ------------------------------------------------------ + + @pytest.mark.asyncio + async def test_base_model_overwrites_empty(self, processor): + """Empty current base_model → new value is applied.""" + llm = {**self.MIN_LLM_OUTPUT, "base_model": "Flux.1 D"} + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview", return_value=False), + mock.patch("py.metadata_ops.refresh_cache"), + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=llm, + metadata={"base_model": ""}, + ) + applied = mock_apply.call_args[0][1] + assert applied["base_model"] == "Flux.1 D" + + @pytest.mark.asyncio + async def test_base_model_does_not_overwrite_existing_civitai(self, processor): + """Existing base_model from CivitAI → not overwritten.""" + llm = {**self.MIN_LLM_OUTPUT, "base_model": "Flux.1 D"} + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview", return_value=False), + mock.patch("py.metadata_ops.refresh_cache"), + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=llm, + metadata={"base_model": "SDXL 1.0", "from_civitai": True}, + ) + # apply IS called (metadata_source, llm_enriched_at) but base_model not in it + applied = mock_apply.call_args[0][1] + assert "base_model" not in applied + + @pytest.mark.asyncio + async def test_base_model_overwrites_existing_hf_model(self, processor): + """Existing base_model from HF → overwritten (LLM is more reliable).""" + llm = {**self.MIN_LLM_OUTPUT, "base_model": "Flux.1 D"} + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview", return_value=False), + mock.patch("py.metadata_ops.refresh_cache"), + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=llm, + metadata={"base_model": "SD 1.5", "from_civitai": False}, + ) + applied = mock_apply.call_args[0][1] + assert applied["base_model"] == "Flux.1 D" + + @pytest.mark.asyncio + async def test_base_model_skipped_when_llm_empty(self, processor): + """LLM returns empty base_model → nothing written.""" + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview", return_value=False), + mock.patch("py.metadata_ops.refresh_cache"), + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=self.MIN_LLM_OUTPUT, + metadata={"base_model": ""}, + ) + applied = mock_apply.call_args[0][1] + assert "base_model" not in applied + + # -- trigger_words --------------------------------------------------- + + @pytest.mark.asyncio + async def test_trigger_words_merged(self, processor): + """New trigger words written when current list is empty.""" + llm = {**self.MIN_LLM_OUTPUT, "trigger_words": ["trigger1", "trigger2"]} + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview", return_value=None), + mock.patch("py.metadata_ops.refresh_cache"), + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=llm, + metadata={"trainedWords": []}, + ) + applied = mock_apply.call_args[0][1] + assert applied["civitai"]["trainedWords"] == ["trigger1", "trigger2"] + + # -- short_description → civitai.description ------------------------- + + @pytest.mark.asyncio + async def test_short_description_written_to_civitai(self, processor): + """short_description written to civitai.description for HF models.""" + llm = {**self.MIN_LLM_OUTPUT, "short_description": "A short summary"} + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview", return_value=None), + mock.patch("py.metadata_ops.refresh_cache"), + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=llm, + metadata={"from_civitai": False}, + ) + applied = mock_apply.call_args[0][1] + assert applied["civitai"]["description"] == "A short summary" + + @pytest.mark.asyncio + async def test_short_description_skipped_for_civitai_model(self, processor): + """short_description NOT written for CivitAI models (has own description).""" + llm = {**self.MIN_LLM_OUTPUT, "short_description": "A short summary"} + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview", return_value=None), + mock.patch("py.metadata_ops.refresh_cache"), + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=llm, + metadata={"from_civitai": True}, + ) + applied = mock_apply.call_args[0][1] + assert "civitai" not in applied or "description" not in applied.get("civitai", {}) + + # -- readme_content → modelDescription ------------------------------- + + @pytest.mark.asyncio + async def test_readme_content_converted_to_model_description(self, processor): + """Raw README converted to HTML and stored as modelDescription.""" + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview", return_value=None), + mock.patch("py.metadata_ops.refresh_cache"), + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=self.MIN_LLM_OUTPUT, + metadata={"from_civitai": False}, + readme_content="# Hello\n\nThis is **bold**.", + ) + applied = mock_apply.call_args[0][1] + assert "

    Hello

    " in applied.get("modelDescription", "") + assert "bold" in applied.get("modelDescription", "") + + @pytest.mark.asyncio + async def test_readme_content_skipped_for_civitai_model(self, processor): + """README content NOT converted for CivitAI models.""" + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview", return_value=None), + mock.patch("py.metadata_ops.refresh_cache"), + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=self.MIN_LLM_OUTPUT, + metadata={"from_civitai": True}, + readme_content="# Hello", + ) + applied = mock_apply.call_args[0][1] + assert "modelDescription" not in applied + + # -- gallery images → civitai.images --------------------------------- + + @pytest.mark.asyncio + async def test_gallery_images_extracted_from_readme(self, processor): + """Widget entries in README → civitai.images.""" + readme = """--- +widget: +- text: "a cat" + output: + url: images/cat.png +--- +Content +""" + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview", return_value=None), + mock.patch("py.metadata_ops.refresh_cache"), + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=self.MIN_LLM_OUTPUT, + metadata={ + "from_civitai": False, + "hf_url": "https://huggingface.co/user/repo", + }, + readme_content=readme, + ) + applied = mock_apply.call_args[0][1] + images = applied.get("civitai", {}).get("images", []) + assert len(images) == 1 + assert images[0]["url"] == ( + "https://huggingface.co/user/repo/resolve/main/images/cat.png" + ) + assert images[0]["meta"]["prompt"] == "a cat" + + @pytest.mark.asyncio + async def test_gallery_images_skipped_for_civitai_model(self, processor): + """Gallery images NOT extracted for CivitAI models.""" + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview", return_value=None), + mock.patch("py.metadata_ops.refresh_cache"), + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=self.MIN_LLM_OUTPUT, + metadata={ + "from_civitai": True, + "hf_url": "https://huggingface.co/user/repo", + }, + readme_content="---\nwidget:\n- text: a\n output:\n url: x.png\n---\n", + ) + applied = mock_apply.call_args[0][1] + civitai = applied.get("civitai", {}) + assert "images" not in civitai + + # -- tags ------------------------------------------------------------ + + @pytest.mark.asyncio + async def test_tags_merged_and_deduplicated(self, processor): + llm = {**self.MIN_LLM_OUTPUT, "tags": ["flux", "lora", "STYLE"]} + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview", return_value=False), + mock.patch("py.metadata_ops.refresh_cache"), + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=llm, + metadata={"tags": ["anime"], "from_civitai": False}, + ) + merged = mock_apply.call_args[0][1]["tags"] + assert "anime" in merged + assert "flux" in merged + assert "style" in merged # lowercased + # "lora" and "STYLE" → "lora" and "style" + assert len(merged) == 4 # anime, flux, lora, style + + # -- metadata_source & llm_enriched_at -------------------------------- + + @pytest.mark.asyncio + async def test_audit_fields_always_set(self, processor): + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview", return_value=False), + mock.patch("py.metadata_ops.refresh_cache"), + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=self.MIN_LLM_OUTPUT, + metadata={}, + ) + applied = mock_apply.call_args[0][1] + assert applied["metadata_source"] == "agent:enrich_hf_metadata" + assert "llm_enriched_at" in applied + + # -- preview download ------------------------------------------------ + + @pytest.mark.asyncio + async def test_preview_downloaded_when_url_provided(self, processor): + llm = {**self.MIN_LLM_OUTPUT, "preview_url": "https://ex.com/img.png"} + with ( + mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply, + mock.patch("py.metadata_ops.download_preview") as mock_dl, + mock.patch("py.metadata_ops.refresh_cache"), + ): + mock_dl.return_value = "/p.webp" + result = await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=llm, + metadata={}, + ) + assert result["preview_downloaded"] is True + mock_dl.assert_awaited_once_with("/p.safetensors", "https://ex.com/img.png") + applied = mock_apply.call_args[0][1] + assert applied["preview_url"] == "/p.webp" + + @pytest.mark.asyncio + async def test_preview_skipped_when_exists(self, processor): + """If current_preview file exists on disk, skip download.""" + llm = {**self.MIN_LLM_OUTPUT, "preview_url": "https://ex.com/img.png"} + with ( + mock.patch("py.metadata_ops.apply_metadata_updates"), + mock.patch("py.metadata_ops.download_preview") as mock_dl, + mock.patch("py.metadata_ops.refresh_cache"), + mock.patch("os.path.exists", return_value=True), + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=llm, + metadata={"preview_url": "/existing/preview.webp"}, + ) + mock_dl.assert_not_called() + + # -- cache refresh --------------------------------------------------- + + @pytest.mark.asyncio + async def test_cache_refreshed_when_updates_applied(self, processor): + llm = {**self.MIN_LLM_OUTPUT, "base_model": "Flux.1 D"} + with ( + mock.patch("py.metadata_ops.apply_metadata_updates", return_value=["base_model"]), + mock.patch("py.metadata_ops.download_preview", return_value=False), + mock.patch("py.metadata_ops.refresh_cache") as mock_ref, + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=llm, + metadata={"base_model": ""}, + ) + mock_ref.assert_awaited_once_with("/p.safetensors") + + @pytest.mark.asyncio + async def test_cache_not_refreshed_when_nothing_changed(self, processor): + with ( + mock.patch("py.metadata_ops.apply_metadata_updates", return_value=[]), + mock.patch("py.metadata_ops.download_preview", return_value=False), + mock.patch("py.metadata_ops.refresh_cache") as mock_ref, + ): + await processor.process( + skill_name="enrich_hf_metadata", + model_path="/p.safetensors", + llm_output=self.MIN_LLM_OUTPUT, + metadata={"base_model": ""}, + ) + mock_ref.assert_not_called() + + +# ====================================================================== +# Unit: _merge_tags +# ====================================================================== + + +class TestMergeTags: + def test_deduplicates_case_insensitive(self): + existing = ["anime", "Flux"] + new = ["flux", "LORA", "anime"] + result = PostProcessor._merge_tags(existing, new) + # All tags are lowercased (matching TagUpdateService behaviour) + assert result == ["anime", "flux", "lora"] diff --git a/tests/services/test_skill_registry.py b/tests/services/test_skill_registry.py new file mode 100644 index 00000000..7c188fb2 --- /dev/null +++ b/tests/services/test_skill_registry.py @@ -0,0 +1,88 @@ +"""Tests for the SkillRegistry (``prompt.md`` discovery + prompt loading).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from py.services.agent.skill_registry import SkillRegistry +from py.services.agent.skill_definition import SkillDefinition, SkillPermissions + + +@pytest.fixture +def registry(): + """Create a SkillRegistry with the real skills directory.""" + SkillRegistry.reset_instance() + reg = SkillRegistry() + reg._discover() + return reg + + +class TestSkillRegistryDiscovery: + def test_discovers_enrich_hf_metadata_skill(self, registry): + skills = registry.list_skills() + assert len(skills) >= 1 + skill = registry.get_skill("enrich_hf_metadata") + assert skill is not None + assert skill.name == "enrich_hf_metadata" + assert skill.llm_required is True + + def test_skill_has_correct_model_type_filter(self, registry): + skill = registry.get_skill("enrich_hf_metadata") + # model_type_filter was removed from prompt.md — defaults to None (all types) + assert skill.model_type_filter is None + + def test_skill_has_permissions(self, registry): + skill = registry.get_skill("enrich_hf_metadata") + assert skill.permissions.write_metadata is True + assert skill.permissions.write_previews is True + # network_domains defaults to () since permissions block was removed + + def test_get_skill_returns_none_for_unknown(self, registry): + assert registry.get_skill("nonexistent_skill") is None + + +class TestSkillRegistryLoading: + def test_load_prompt_returns_content(self, registry): + prompt = registry.load_prompt("enrich_hf_metadata") + assert isinstance(prompt, str) + assert len(prompt) > 100 + assert "base_model" in prompt + assert "trigger_words" in prompt + + def test_load_prompt_raises_for_unknown_skill(self, registry): + with pytest.raises((FileNotFoundError, ValueError)): + registry.load_prompt("nonexistent") + + +class TestSkillDefinition: + def test_applies_to_model_type_with_filter(self): + sd = SkillDefinition( + name="test", + title="Test", + description="", + llm_required=False, + model_type_filter=["lora"], + ) + assert sd.applies_to_model_type("lora") is True + assert sd.applies_to_model_type("checkpoint") is False + + def test_applies_to_model_type_without_filter(self): + sd = SkillDefinition( + name="test", + title="Test", + description="", + llm_required=False, + model_type_filter=None, + ) + assert sd.applies_to_model_type("lora") is True + assert sd.applies_to_model_type("checkpoint") is True + + +class TestSkillPermissions: + def test_defaults(self): + sp = SkillPermissions() + assert sp.write_metadata is True + assert sp.write_previews is True + assert sp.network_domains == ()
    A1