Compare commits

..

95 Commits

Author SHA1 Message Date
Will Miao 8022d12f03 chore(release): bump version to v1.1.8 2026-07-20 20:42:04 +08:00
Will Miao 3939f7f91b chore: Update lora manager basic example workflow 2026-07-20 20:20:35 +08:00
Will Miao aebf2e37dd fix(filter): apply preset on full tile click and suppress i18n double-translate warnings
- Move preset apply handler from span.preset-name to div.filter-preset so
  clicking anywhere on the tile triggers the preset, not just the label text.
- Add whitespace heuristic in showToast() to skip translate() for plain
  messages that are already translated at the call site. This prevents
  i18next from logging 'Translation key not found' for pre-translated
  strings like 'Preset "name" applied'.
2026-07-20 17:57:14 +08:00
Will Miao f53f859a71 feat(filter): add debounced tag search with backend search-tags endpoint 2026-07-20 17:37:47 +08:00
Will Miao d916375abe fix(checkpoint): populate hash index from pre-computed metadata to prevent repeated hash re-calculation (#1002) 2026-07-20 12:24:54 +08:00
Will Miao 57983df4bd fix(recipe): resolve recipe metadata update bugs in cache sort, allowed fields, and bulk API routing
- Use safe .get() in RecipeCache._resort_locked instead of itemgetter to prevent KeyError when recipe missing created_date; align sort key with _sort_cache_sync (prefer modified, fallback created_date, fallback 0)
- Add base_model to allowed_fields in persistence_service.update_recipe() so the field passes validation
- Route bulk base model updates through updateRecipeMetadata() on recipes page instead of generic saveModelMetadata(), matching existing isRecipesPage pattern used in setBulkFavorites and saveBulkTags
2026-07-20 11:20:06 +08:00
Will Miao c68d7559a0 fix(widget): correct reorder drop indicator position when container is scrolled
The drop indicator top position was calculated using only
getBoundingClientRect() offsets (post-CSS-transform viewport space)
without accounting for container.scrollTop (pre-transform layout space).
This caused the indicator to drift upward as the user scrolled down,
eventually disappearing entirely.

Fixed by adding container.scrollTop to the position calculation and
only dividing the GBCR visual-diff portion by scale, since scrollTop
is already in pre-transform coordinate space.
2026-07-19 22:40:07 +08:00
Will Miao 9a8f5bf2d6 fix(ui): reposition download settings before AI provider section 2026-07-19 17:57:02 +08:00
Will Miao a8d742b031 feat(metadata): add CivArchive API toggle and provider fallback order settings
- Add enable_civarchive_api toggle (default on) to allow disabling
  CivArchive to avoid its rate-limit windows entirely
- Add metadata_provider_order dropdown with two presets:
  CivitAI → CivArchive → Archive DB (default) and
  CivitAI → Archive DB → CivArchive
- Wire both settings through backend (metadata_service, settings_manager,
  misc_handlers) and frontend (SettingsManager, state, settings modal)
- Reorder Metadata section in settings modal: toggles → status/management
  → fallback order, for natural top-down workflow
- Make update_metadata_providers() log the effective provider chain
  using actually-registered providers rather than settings assumptions
- Add 5 test cases covering all provider-combination paths
- Complete i18n translations for 6 new keys across all 9 non-English locales
2026-07-19 17:51:50 +08:00
Will Miao c27e4d1bfc feat(cache): opportunistic cache sync on metadata read with in-place update
- Add PersistentModelCache.update_single_model() for lightweight targeted
  SQL update (single row + incremental tag/hash deltas, no full table scan)
- Add ModelScanner.sync_cache_from_metadata() with compare-first logic:
  skips entirely when cache is already in sync; when stale, updates the
  entry in-place (O(1) instead of O(n) remove+append), incrementally
  adjusts tag counts/hash index/version index, and resorts only when
  sort-relevant fields changed
- Wire sync_cache_from_metadata() into BaseModelService.get_model_metadata()
  via fire-and-forget asyncio.create_task — disk I/O is already paid for
- Include identity re-validation guard against concurrent cache replacement
- Add 16 tests covering _cache_entries_differ, sync_cache_from_metadata
  (no-change, in-place, fallback, conditional resort), and
  update_single_model (insert, tag delta, hash delta)
2026-07-19 08:32:21 +08:00
Will Miao d15a8aa9a2 fix(workflow): accept non-string widget values and support GlobalSeed node in gen-params (#1026) 2026-07-18 22:57:20 +08:00
Will Miao 74a7d12ca4 fix(test): add missing options mock in LoraInfoWidget test 2026-07-18 22:14:03 +08:00
Will Miao 2f94a9773e feat(workflow): redirect gen-params updates to connected Primitive nodes (#1026)
When a KSampler marked as 'Send Gen Params Target' has widget inputs
wired to Primitive nodes (PrimitiveNode, PrimitiveInt, PrimitiveFloat,
etc.), sending gen params from the Lora Manager UI now updates the
Primitive node's value instead of the KSampler widget. This is
necessary because ComfyUI's execution engine reads from the connected
input, ignoring the widget value when a wire is present.

Also fix two minor issues found during review:
- Remove unnecessary String() wrapping on numeric gen params (seed,
  steps, cfg) to preserve native types through the JSON/WS path
- Correct misleading isNodeEnabled comment: LGraphEventMode values
  are 0=Always, 2=Never, 4=Bypass (not 'Normal/Enabled')
2026-07-18 22:10:48 +08:00
Will Miao 37bdfa21ea fix(standalone): ensure sys.path includes script dir for python_embeded compatibility (#1025) 2026-07-18 21:22:25 +08:00
Will Miao f0bf2728c9 fix(downloads): accept download_id in history delete/retry endpoints, add unique index 2026-07-18 21:10:42 +08:00
Will Miao dc715aa273 fix(download): fallback to downloadUrl when all mirrors are deleted
When Civitai returns 404 for /models/{id} (e.g. due to Civitai API bug
where un-deleted models still get 404), the fallback to CivArchive
provides metadata.  However CivArchive may return mirrors with every
entry marked deletedAt, while the file's downloadUrl is still valid.

Before this fix, _build_download_urls_from_file_info used an if/else
that skipped the downloadUrl fallback whenever the mirrors array was
non-empty, even when all mirrors were filtered out.  Now downloadUrl
is always tried when no usable mirror remains.

Also deduplicated the inline mirror-processing code at the second call
site by replacing it with a call to the shared helper.
2026-07-18 18:28:32 +08:00
Will Miao 7ee2361e87 fix(config): remove stale 'default' library entry and consolidate example images on startup 2026-07-18 17:25:36 +08:00
Will Miao e04c22f83f fix(widgets): allow text selection in LoraInfoWidget description tab 2026-07-17 18:33:59 +08:00
Will Miao 681cc13e90 fix(widgets): persist LoRA entry selection and active tab across save/load 2026-07-17 18:27:34 +08:00
Will Miao 090e0297d4 fix(downloader): hold session lock in retry paths to prevent session close race
Refactor _create_session() to make-before-break: snapshot old session,
assign new one first, then close old.  Previously, concurrent download
retries called _create_session() without the session lock (violating its
docstring contract) and closed the old session while other coroutines
held active references — causing aiohttp to raise "NoneType has no
attribute connect" when dereferencing the torn-down connector.

Also wrap the two _create_session() calls in the integrity-retry and
network-retry paths with self._session_lock to match the locking
discipline used by the session property and refresh_session().
2026-07-17 17:21:05 +08:00
Will Miao 6f71335be4 feat(widgets): add Description tab to LoraInfoWidget with dual-mode rendering support
- Add Notes/Description tab switching with tab state persistence in widget value
- Lazy-load model description and version description from /lm/loras/metadata
- Render CivitAI HTML descriptions inline via v-html
- Auto-fetch description when LoRA selection changes while on Description tab
- Fix Vue mode height containment via contain:layout size (lm-vue-node class)
- Fix scroll wheel isolation: widget scroll vs canvas zoom in both render modes
- Add docs/comfyui-dual-mode-widgets.md with widget rendering patterns
2026-07-17 15:04:47 +08:00
Will Miao 7f51812c1e feat(nodes): add LoRA Syntax → Path node (#1015) 2026-07-16 19:57:29 +08:00
Will Miao a9dc4d7b9d fix(widgets): reuse orphaned DOM containers after undo/redo in Vue render mode
In ComfyUI Vue render mode, WidgetDOM.vue reuses its component instance
during undo/redo without re-calling mountWidgetElement(), leaving newly
created widget containers detached from the DOM.

- AutocompleteTextWidget: scan for empty containers by ID prefix and reuse
- Loras widget: scan for empty .lm-loras-container elements and reuse
- Prevent duplicate event listeners by guarding listener setup on new
  containers only
- Keep container in DOM on cleanup (clearChildren instead of remove)
  so it can be found and reused by the next factory invocation
2026-07-16 18:54:00 +08:00
Will Miao 5d50ddb5d4 fix(ui): exit bulk mode after send-to-workflow completes 2026-07-16 18:54:00 +08:00
Will Miao f86198d234 fix(loras): include folder prefix in context menu and bulk send-to-workflow
When using full path lora syntax, the context menu (single/bulk)
and bulk copy actions were passing only the file basename to
buildLoraSyntax(), ignoring the folder prefix. This caused the
output to look like legacy A1111 format even when full path mode
was enabled.

Aligns all entry points with ModelCard.handleSendToWorkflow(),
which correctly includes the folder prefix.

Also fixes selectAllVisibleModels() to cache the folder field,
preventing missing prefix on select-all-then-send flows.
2026-07-16 18:54:00 +08:00
Will Miao ffe65d983c feat(api): add GET endpoints for update-lora-code and update-node-widget
Add GET variants of the two POST endpoints used by the send-to-workflow
feature. Parameters are read from query string instead of JSON body,
supporting both simple repeated node_id params and JSON-encoded node_ids
for complex graph references.
2026-07-15 21:49:22 +08:00
Will Miao b0b5be913c fix(downloads): reject re-insertion of download_ids already in history
In add_to_queue, check download_history before INSERT OR IGNORE.  Without
this check, a fire-and-forget /queue/complete failure on the extension side
would allow the same download_id to be re-inserted after complete_download()
deleted it from the queue — creating phantom queued entries for already-
finished downloads.
2026-07-15 19:12:22 +08:00
Will Miao 01efcbc584 fix(loras): allow toggle deselect on LoRA entry click 2026-07-14 18:23:06 +08:00
Will Miao 02c249917a fix(recipe): ensure custom recipes_path is added to preview allowed roots on startup 2026-07-14 18:15:20 +08:00
Will Miao 419bbc90b2 feat(lora-info): add Lora Info display node
Add a pure frontend node that shows filename and editable notes for
a selected LoRA. Connect any output from a LoRA Loader/Stacker/Randomizer/
WanVideoSelect to the lora_source input — selecting a LoRA in the source
widget updates the info display automatically.

- Python node (LoraInfoLM): display-only, no workflow execution
- Vue widget: filename label, auto-sizing notes textarea, save button
  with ComfyUI toast feedback on save
- Frontend extension: wire-based selection propagation with stale-response
  race guard; clears display on wire disconnect
- Backend: get-notes endpoint now returns file_path alongside notes;
  matching supports full-path lora syntax; fix NoneType crash in
  trigger words endpoint; document cache file_name invariant
- Wired into all four lora widget nodes (Loader, Stacker, Randomizer,
  WanVideoSelect)
2026-07-14 18:00:31 +08:00
willmiao b0c4510fdb docs: auto-update supporters list in README 2026-07-13 14:18:36 +00:00
Will Miao bf6a614e0d chore(release): bump version to v1.1.7 2026-07-13 22:18:16 +08:00
Will Miao feab01cd9c fix(preview): hide license icons for models without CivitAI metadata 2026-07-13 19:49:10 +08:00
Will Miao 966024e534 fix(registry): force re-registration on WS refresh to prevent timeout, demote empty-registry log to debug
- workflow_registry.js: add force param to refreshRegistry(), bypass fingerprint
  dedup when responding to lora_registry_refresh WS message. Without this, the
  backend's wait_for_all() times out after 0.5s because the frontend skips the
  register-nodes POST when the workflow fingerprint hasn't changed (common after
  ComfyUI restart with an empty or unchanged workflow).
- misc_handlers.py: demote 'No nodes registered after refresh' from WARNING to
  DEBUG — empty workflows are a normal operational state, not a warning-worthy
  condition.
2026-07-13 19:10:48 +08:00
Will Miao 2018722cc8 fix(registry): handle compound subgraph node IDs, add proactive node push from graph hooks
- Handle compound node IDs (e.g. "252:0") from expanded group subgraphs
  to fix 400 Bad Request on workflows with group nodes
- Frontend proactively pushes node data via afterConfigureGraph and
  LiteGraph hooks (onNodeAdded/onNodeRemoved/graphChanged), eliminating
  WebSocket round-trip latency for most "Send to Workflow" operations
- Add content-fingerprint dedup to skip duplicate register-nodes POSTs
- Fast-path cache returns immediately when tabs are registered (including
  0-node registrations), avoiding unnecessary WS refresh cycles
- Distinguish "Empty Registry" from other errors in standalone UI toast
- Reduce WS refresh timeout 2s→0.5s, add cooldown and lock to prevent
  concurrent refresh storms
- All [LM:Registry] logs at DEBUG level
2026-07-13 18:02:26 +08:00
Will Miao 9d85c2a44a fix(ui): prevent tags widget from auto-resizing in Vue mode when tags change 2026-07-13 14:55:40 +08:00
Will Miao 03dd047e62 fix(download): return 200 instead of 500 when user cancels download 2026-07-13 11:47:48 +08:00
Will Miao 86b547c1e0 fix(locales): add missing downloadStopped key to toast.downloads section 2026-07-13 11:35:48 +08:00
Will Miao bab9752c8b fix(download): close modal before progress overlay and fix downloadId ReferenceError on cancel 2026-07-13 11:29:47 +08:00
Will Miao 774cc1be86 fix(download): use file ID for exact match, add debug logging for multi-file selection (#1023)
- Frontend: send file.id in file_params, use null instead of hardcoded defaults
- Backend: priority matching (ID exact → primary → lenient metadata)
- Lenient metadata: only compare fields present on both sides (fixes GGUF size mismatch)
- Add debug logs at key points: entry, file_params received, match result, anomaly signals
2026-07-13 11:15:03 +08:00
Will Miao 234b73c8a2 feat(ui): add cancel button to download progress modal 2026-07-13 09:40:53 +08:00
Will Miao abd06c48f4 fix(settings): reject checkpoints↔unet path overlap in extra folder paths with inline error UI
Changes:
- Backend: _validate_folder_paths() now checks checkpoints↔unet overlap
  within the same library using os.path.realpath() for symlink resolution
- Backend: set() calls _validate_folder_paths() for both folder_paths and
  extra_folder_paths before writing
- Backend: extracted _normalize_path_set() helper to eliminate duplicated
  normalization logic
- Frontend: inline error display with red border + error message below the
  conflicting input, no save triggered
- Frontend: path normalization (strip trailing slash, lowercase) in pre-check
  to reduce false negatives vs backend realpath
- Frontend: asymmetric error UX — message only on the user-edited side,
  red border on the pre-existing conflict side
- CSS: has-error styles with hardcoded rgba fallback for older browsers
- i18n: checkpointUnetOverlap + checkpointUnetOverlapInline keys added to
  all 10 locale files
2026-07-13 08:22:40 +08:00
Will Miao 6ca411e4e4 fix(ui): make loras widget fixed-size with user-controlled node resize
Remove dynamic height calculation that auto-resized the node when
LoRAs are added or removed. The widget now stays at the size the user
sets via the node resize handle, scrolling when content overflows.

- Drop updateWidgetHeight() and hardcoded entry-count height math
- Set --comfy-widget-min-height once (200px) instead of recalculating
- In Vue mode: add contain:layout+size to break the ResizeObserver
  feedback loop that forced node growth with content (CSS via
  .lm-loras-container.lm-vue-node scoped to vueNodesMode only)
- Remove unused "Node 2.0: Maximum visible LoRA entries" setting
2026-07-12 22:35:58 +08:00
Will Miao 6470021e77 feat(settings): persist LORA_MANAGER_PORTABLE to settings.json on first use (#1018) 2026-07-12 09:32:30 +08:00
Will Miao 71658ab37b feat(settings): add LORA_MANAGER_PORTABLE env var for per-instance settings isolation (#1018) 2026-07-12 07:44:31 +08:00
Will Miao 4f016a8024 feat(fetch): skip CivArchive API for HuggingFace-sourced models
- Bulk refresh filter now excludes models with hf_url
- Individual refresh for HF models only checks CivitAI API
- CivArchive client validates model IDs before querying
2026-07-11 20:29:54 +08:00
Will Miao f362ed585b fix(preview): gracefully handle deleted preview files - image fallback, cache cleanup, quieter logs
- Add onerror handler on <img> previews to fallback to no-preview.png
- Fire async cache cleanup when preview file returns 404
- Add ModelCache.clear_preview_by_path() for safe stale-url removal
- Downgrade /api/lm/previews 404 log from warning to debug
2026-07-10 21:25:07 +08:00
Will Miao 196172624f fix(ui): allow autocomplete textarea resize in app mode (#1020) 2026-07-09 11:59:09 +08:00
Will Miao 316702b7ab fix(hf): allow subdirectory paths in HF resolve URLs, strip repo-internal dirs on save (#1019) 2026-07-09 09:18:38 +08:00
Will Miao a7625b009f fix(ui): also exit bulk mode after enrich-hf-llm-bulk completes 2026-07-07 20:31:16 +08:00
Will Miao 5d4a33c90d fix(hf): stop using realpath for download path construction, match CivitAI approach 2026-07-07 20:24:47 +08:00
Will Miao 041a6b8525 Revert "fix(hf): pass computed folder to _save_hf_metadata instead of re-deriving from paths"
This reverts commit 54b44131b6.
2026-07-07 20:13:20 +08:00
Will Miao 2638109ad6 feat(hf): add Link to HuggingFace feature with unified Link Model submenu
- Merge Relink to Civitai and new Link to HuggingFace into a single
  'Link Model' submenu with sub-options for each source
- Add POST /api/lm/set-hf-url endpoint to associate a model with a
  HuggingFace repo URL, saving hf_url to .metadata.json
- Add link_hf_modal.html for URL input, following relink-civitai pattern
- Use update_single_model_cache instead of add_model_to_cache to
  prevent duplicate cache entries after linking
- Remove os.path.realpath usage for consistency with relink-civitai
- Raise errors instead of silently falling back to LoRA scanner when
  model root cannot be determined
- Scope .input-group CSS rules to modal IDs to fix style conflicts
  with download-modal.css
- Add i18n keys across all 10 locales with translations for
  zh-CN, zh-TW, ja, ko, de, es, fr, he, ru
2026-07-07 20:04:47 +08:00
Will Miao b019326747 feat(ui): auto-exit bulk mode after all bulk operations complete 2026-07-06 18:51:33 +08:00
Will Miao 54b44131b6 fix(hf): pass computed folder to _save_hf_metadata instead of re-deriving from paths 2026-07-06 17:34:43 +08:00
Will Miao a1d948025c fix(hf): strip empty trainedWords from metadata JSON to keep sidecar clean 2026-07-06 16:49:51 +08:00
Will Miao a90b2514ba feat(ui): group HF batch files by repo with collapse/expand, fix nested scroll & collapse animation
- Group HF batch download files by repo with collapsible group headers
- Fix nested scrollbar conflict (inner scrollbar undraggable) by making batch-preview-list flex-fill
- Fix collapse animation glitch (items disappearing before container shrinks) by keeping expanded during max-height transition
- Visual polish: hover lift, backdrop-filter glass, design token alignment
- Remove redundant database icon from group header
- Guard transitionend handlers against rapid-click races
2026-07-06 16:36:26 +08:00
pixelpaws cb4ad27813 Merge pull request #1013 from willmiao/agent
Hugging Face model metadata AI enrichment
2026-07-06 12:21:19 +08:00
Will Miao 637831248b fix(agent): route WS error events through onError instead of dead onComplete branch 2026-07-06 12:18:17 +08:00
Will Miao 00228deaaa fix(download): retry on Civitai 429 rate limit instead of removing images from metadata
When Civitai returns 429 (Too Many Requests) during example image
downloads, the previous behavior treated all failures identically and
permanently removed the corresponding images from model metadata —
making them impossible to retry.

This commit adds:
- 429 detection + Retry-After header parsing in download_to_memory
- Exponential backoff retry (up to 3 attempts) in
  download_model_images_with_tracking
- Separate tracking of rate-limited vs permanently failed URLs
- rate_limited_models progress tracking persisted to disk
- Rate-limited models are NOT added to failed_models/processed_models
  so they are automatically retried on subsequent download runs
- Force mode clears failed_models when rate-limited images exist
2026-07-06 11:58:19 +08:00
Will Miao 2373edf73c feat(ui): load provider model catalog asynchronously to avoid blocking page render 2026-07-06 10:02:09 +08:00
Will Miao e0e1b804a7 fix(llm): require api_base for custom provider without preset default 2026-07-06 10:02:04 +08:00
Will Miao fecbe8241f fix(agent): use status= instead of status_code in json_response calls 2026-07-06 10:02:00 +08:00
Will Miao 5983eaa1ce refactor(llm): use catalog-based max_tokens, remove JSON retry, reduce Ollama num_ctx
- Parse limit.output from model catalog alongside model IDs
  for per-model max output token limits
- Use catalog lookup in chat_completion_json() to set max_tokens;
  fall back to 4096 for unknown models (e.g. local Ollama)
- Remove the JSON retry (response_format → plain text fallback);
  keep _try_salvage_json as last-resort for truncated responses
- Reduce Ollama num_ctx from 32768 to 8192 (sufficient for
  metadata enrichment, saves VRAM)
- Fix stale test comment referencing removed retry
2026-07-06 09:13:42 +08:00
Will Miao 07fa454f72 chore(tests): stop tracking HF enrichment baseline snapshots
Remove tests/enrich_hf_validation/baselines/ from git tracking
(.gitignore entry + git rm --cached). These contain README snapshots
from community HF repos that may include NSFW/sensitive content.

Local files are preserved on disk for offline reference.
2026-07-06 01:08:25 +08:00
Will Miao 4b5aa45379 chore(tests): update bash code block tests to match preserved-bash behavior
Commit 9a0d866b changed _strip_fenced_code_blocks to preserve bash/shell
code blocks (they carry CLI setup and trigger-word metadata signal).
Update the two affected tests to expect bash content in the output
instead of asserting it is stripped.

- Rename test_bash_code_block_stripped → test_bash_code_block_preserved
- Update assertions: expect 'pip install' in result
2026-07-06 01:02:04 +08:00
Will Miao 9a0d866be4 fix(agent): preserve bash/shell code blocks in readme_processor during README cleaning 2026-07-06 00:40:35 +08:00
Will Miao 308d8f71b8 feat(ui): gray out enrich-hf-llm when no hf_url, add backend fast-fail, rename labels across locales, reposition menu item 2026-07-06 00:34:18 +08:00
Will Miao d0e8938039 fix(agent): call _format_base_models via self. to prevent NameError
The bare call  inside _build_prompt_context
would raise NameError because class methods don't close over class-level
scope. Use  instead to trigger attribute lookup.

Update enrich_hf_metadata prompt.md clue locations for better LLM accuracy.
Update baseline report to v2 (mean 69.0, 46 models, +2.2pp vs baseline 71.1%).
Consolidate README snapshots into baselines/readmes/.
2026-07-06 00:10:30 +08:00
Will Miao 13ed898b6b chore(tests): add base_model ground truth mapping for all 46 test entries 2026-07-05 20:47:30 +08:00
Will Miao e1dfd1c2a6 chore(tests): add two test entries and their HF README snapshots 2026-07-05 20:45:01 +08:00
Will Miao e3e944911b refactor(agent): extract shared scanner iteration into _find_model_entry
_Previous_ _find_scanner_for_model and identify_model_type contained ~25 lines
of identical scanner-iteration + path-matching logic.  Factor it into
_find_model_entry() so a new scanner type or edge-case fix can't drift apart.
2026-07-05 18:03:57 +08:00
Will Miao 51c0135250 refactor(agent): rename agent_cli to metadata_ops, strip temp debug logs
- Rename py/agent_cli/ -> py/metadata_ops/ (module was never agent-related)
- Rename tests/agent_cli/ -> tests/metadata_ops/
- Remove 9 low-value/debug INFO log points across agent_handlers.py,
  agent_service.py, llm_service.py, and metadata_ops/__init__.py
- Keep LLM raw response at DEBUG level for diagnostics
- Consolidate per-model progress + LLM result into single concise
  log line with basename instead of full path
- Update package/class/method docstrings to clarify this is a
  pipeline infrastructure, not a true agent loop
2026-07-05 18:00:58 +08:00
Will Miao 7b19bbb14e fix(agent): preserve preview URLs for collection repo models with flat heading structure
Three-part fix for enrich_hf_metadata failing to extract correct preview_url
from HuggingFace collection repos where models share flat heading levels:

1. _strip_standalone_images() now converts <img> tags to markdown image
   syntax ![alt](src) instead of stripping the URL entirely, so the LLM
   can still extract preview URLs.

2. _extract_section() uses a line-count-based forward window (stopping at
   <a id> anchors) for non-heading matches, instead of stopping at the
   very next heading. This prevents same-level sub-headings (# Download,
   # Trigger, # Sample prompt within a single model section) from
   truncating the window before sample images are included.

3. Post-processor preview fallback now filters gallery images to the
   model-specific README section before falling back to the repo-wide
   first image.
2026-07-05 17:05:47 +08:00
Will Miao 5494a70f40 chore(tests): commit validation dataset and baseline reports into repo
Move the HF model list from ~/Documents/ into tests/enrich_hf_validation/test_data/
and commit the pipeline validation baseline artifacts (report.json,
preprocessing_audit.json, README snapshots) into baselines/.

Update config.py and run_validation.py defaults to use repo-relative paths
via os.path.dirname(__file__) instead of ~/Documents/ hardcode.

Originates from changes in 8fb00998 (validation pipeline audit).
2026-07-05 17:03:45 +08:00
Will Miao 26c9ade1c9 feat(agent): optimize base model prompt — grouped display, comprehensive mapping rules, filename inference
- agent_service._format_base_models: output bullet list instead of
  JSON array for cleaner LLM parsing
- prompt.md mapping section: replace 14-row HF→CivitAI table with
  compact rule set covering 14 mapping paths including new entries
  for HiDream-ai, OnomaAIResearch/Illustrious, ideogram-ai/ideogram,
  Tongyi-MAI/Z-Image-Turbo, and Wan-AI/Wan2.*
- base_model extraction instruction: add guidance to infer from
  model filename, YAML tags, and README body text when YAML
  frontmatter has no explicit base_model:
2026-07-05 15:45:17 +08:00
Will Miao 87db23825f feat(constants): add 12 new CivitAI base models from API, sync JS/Python abbreviations and categories 2026-07-05 11:44:53 +08:00
Will Miao 8fb00998a7 feat(agent): fix extract_relevant_section false positives, add validation pipeline audit
- extract_relevant_section: raise token threshold >3, verify anchor
  sections contain basename, require 2+ heading token overlaps, skip
  TOC-style headings (markdown links), verify heading section size
- metadata_constructor: parse repo_id,model_name.safetensors format
  so model_path basename matches real filename
- config: replace hardcoded SUPPORTED_BASE_MODELS with dynamic
  init_supported_base_models() using production list_base_models()
- preprocessing_auditor: new Phase 1.5 audit module — fetches each
  README, runs extract_relevant_section + clean_readme_for_llm,
  records stats and flags, saves raw READMEs for cross-reference
- run_validation: integrate audit phase, add --audit-only mode,
  add LLM config consistency check, add ComfyUI root to sys.path
- report_generator: add Preprocessing Audit and Config Warnings
  sections to both markdown and JSON reports
2026-07-05 11:18:48 +08:00
Will Miao dd3aa97d0a refactor(agent): rename md_to_html to readme_processor, fix section extraction, widget parsing, and list_base_models
- Rename md_to_html.py → readme_processor.py (file no longer just HTML conversion)
- _extract_section: include YAML frontmatter, use heading-level-aware forward
  walk (sub-headings under # are included), increase walk limit past 30 lines
- _is_heading: exclude </hN> closing tags from boundary detection
- _heading_level: new helper for heading-level-aware section matching
- css: yield 0 for heading like closing tags, was unexpectedly caught by _is_heading
- extract_gallery_images: fix YAML block scalar (text: >-) prompt extraction;
  use endswith instead of == to detect the block marker
- _strip_widget_section: add to clean_readme_for_llm (widget text is handled
  by post-processor, not needed in LLM prompt)
- _strip_standalone_images: keep markdown image URLs intact for LLM preview
  extraction (was stripping to alt text only)
- list_base_models: switch from scanner-cache aggregation to
  CivitaiBaseModelService.get_base_models() - always returns full list
- Ollama: add num_ctx=32768 to payload options so thinking models have room
  to both reason and produce output
- Add tests/agent_cli/test_readme_processor.py: 59 tests covering extraction,
  cleaning, section matching, heading detection
- Update existing tests for behavioral changes
2026-07-05 06:39:54 +08:00
Will Miao 8bee8f4069 fix(recipe): fallback to locate custom example image on disk by model hash and image id (#1012) 2026-07-04 18:40:34 +08:00
Will Miao 817fe21b3e fix(ui): read cfg_scale and clip_skip with snake_case fallback, pass custom image id for recipe creation (#1012) 2026-07-04 18:40:24 +08:00
Will Miao 905c37290f chore: update runtime logs to use 'LLM enrichment' instead of 'Agent skill'
- agent_handlers.py: 'Agent skill' -> 'LLM enrichment' in all log messages
- skill_registry.py: 'agent skills' -> 'prompt-based skills' in discovery log
- llm_service.py: docstring 'agent skills' -> 'LLM-based enrichment features'
2026-07-04 16:53:41 +08:00
Will Miao f7632a47f9 feat(agent): enrich_hf_metadata with per-model progress and in-place card update
- PostProcessor returns updates dict from enrich_hf_metadata
- AgentService includes updated_data per model in WebSocket progress events
- Convert preview_url to HTTP URL via config.get_preview_static_url()
- LoraContextMenu: showEnhancedProgress + updateSingleItem per model
- BulkContextMenu: same pattern, remove window.location.reload()
- Guard empty updated_data and clean up callbacks on HTTP error
2026-07-04 16:50:56 +08:00
Will Miao 646f1ddfb1 refactor(agent): align 'Agent' naming to 'AI/LLM' to match current implementation
- locales/en.json: 'Enrich Metadata (Agent)' -> 'Enrich Metadata (AI)'
- Rename SKILL.md -> prompt.md with backward compat in skill_registry.py
- JS context menu action IDs: enrich-hf-agent -> enrich-hf-llm
- HTML template data-action attributes synced to match
- docstring cleanup: 'agent skill' -> 'skill pipeline' / 'feature'
2026-07-04 14:06:50 +08:00
Will Miao 170c8068c5 feat(agent): enrich_hf_metadata — filename-aware section matching, preview extraction for markdown/HTML/widget, JSON salvage, instance_prompt fallback, and validation suite
- extract_relevant_section(): trim README to model-filename-matching section
  for collection repos (download link, anchor ID, heading strategies)
- _strip_standalone_images(): preserve markdown image URLs so LLM can
  extract preview_url; strip only HTML <img> tags
- extract_simple_markdown_images(): extract civitai.images from ![]() body
- extract_html_img_tags(): extract from <img src="..."> (deadman44-style)
- extract_gallery_images(): fix widget parser for YAML - output: dash prefix
- _is_heading: exclude </hN> closing tags from boundary detection
- _extract_section: start at matching heading when match IS a heading line
- _try_salvage_json(): recover truncated JSON (close braces/brackets in
  LIFO order, close unterminated strings, strip trailing commas)
- PostProcessor: store _llm_confidence, add instance_prompt YAML fallback
- agent_service: pass model_basename to prompt, trim README via
  extract_relevant_section before clean_readme_for_llm
- Add tests/enrich_hf_validation/ suite: 100-model pipeline with progress
  checkpoint/resume, per-field scoring, markdown+JSON reporting
- Fix evaluation_engine: read _llm_confidence (not _llm_response)
2026-07-04 12:00:15 +08:00
Will Miao 3494037d20 fix(download): pass proxy to aria2 for actual file transfers (#1010) 2026-07-04 11:07:18 +08:00
Will Miao a1fd4e150b feat(agent): optimize enrich_hf_metadata with README cleaning, Ollama native API, and expanded fields
- Add clean_readme_for_llm() to strip noise from README before LLM injection
- Keep widget section text (valuable tag signal) and unmarked code blocks (trigger words)
- Preserve standalone image alt text instead of removing entirely
- Switch Ollama to native /api/chat with think:false to fix empty content on thinking models
- Extract Sample Gallery table images and deduplicate with widget images
- Only strip code blocks with explicit language tags (bash)
- Add notes and usage_tips fields to SKILL.md output format and post-processor
- Clean up dead code, fix regex edge cases, remove double type annotation
2026-07-04 08:01:50 +08:00
Will Miao b22f09bd1d fix(standalone): load extra folder paths from library settings in standalone mode 2026-07-03 19:21:56 +08:00
Will Miao 4ed9169646 feat(ui): redesign AI Provider settings with provider presets and model catalog
- Replace hardcoded provider list with PROVIDER_PRESETS (OpenAI, Ollama,
  DeepSeek, Groq, OpenRouter, OpenCode Go, Custom)
- Load model lists from models.dev/api.json catalog at startup
- Add Combobox vanilla JS component for model/base-URL selection
- Fetch local Ollama models via live API instead of catalog
- Hide API key values from frontend (boolean-only llm_api_key_set)
- Add i18n translations for all 9+ locales
- Update snapshot tests for new response fields
2026-07-03 16:08:51 +08:00
Will Miao f06c60bd47 fix(agent): handle plain YAML scalar text in extract_gallery_images
Widget entries with unquoted multi-line YAML scalars (e.g. "text: two samurais...\n  continuation") were not parsed, leaving gallery image prompts empty. Add a third branch for plain scalar format alongside the existing quoted and >- folded block handlers.
2026-07-03 07:34:24 +08:00
Will Miao ee8250c26c feat(agent): extract HF widget gallery images into civitai.images with recommended dimensions
- Add extract_gallery_images() to parse YAML widget entries from README
  frontmatter, convert relative image URLs to absolute HF URLs, and
  build civitai.images-compatible entries with prompt metadata
- LLM now extracts recommended_width/recommended_height from README
  (e.g. "Best Dimensions"), used as gallery image dimensions
- extract_gallery_images() accepts default_width/height parameters,
  falling back to 512x512 when LLM provides no recommendation
- Frontend ShowcaseView.js: defensive NaN guard for 0 width/height
- post_processor: consistently merge civitai updates across triggers,
  description, and gallery blocks with distinct variable names
- SKILL.md: add recommended_width/recommended_height to output schema
- 62 tests pass, including gallery extraction and dimension tests
2026-07-03 07:07:19 +08:00
Will Miao 88349bf944 feat(agent): render HF README as HTML in modelDescription, move converter to skill-local module
- Add inline convert_readme_to_html() in new skill-local md_to_html.py
  (zero external deps, handles h1-h4/bold/italic/code/lists/tables/links/hr)
- Strip YAML frontmatter, <Gallery />, badge images, HTML comments pre-conversion
- Fix indented whitespace after lists being misidentified as code blocks
- Fix HTML double-escaping in _inline_md (each pattern escapes independently)
- LLM short_description → civitai.description ("About this version" sidebar)
- raw README HTML → modelDescription (description tab, always available offline)
- Pass full readme_content from agent_service to post_processor
- 51 tests for converter + 4 updated/added post-processor tests
2026-07-02 23:34:52 +08:00
Will Miao a8adcaf023 feat(agent): improve enrich_hf_metadata skill with priority_tags, preview_url fix, civitai.trainedWords
- Add identify_model_type() helper to determine lora/checkpoint/embedding
- Pass priority_tags from user settings to LLM prompt for tag relevance
- SKILL.md: instruct LLM to exclude technical/generic HF tags, cross-reference
  against priority_tags; forbid ['None'] placeholder for trigger words
- post_processor: fix preview_url not updated after download (now writes local
  .webp path to metadata); write trigger words to civitai.trainedWords instead
  of top-level; sanitize ['None']/'null'/'n/a' placeholder values to []
- download_preview() now returns str | None (local path) instead of bool
- Update tests for new return type and nested civitai.trainedWords structure
2026-07-02 22:14:44 +08:00
Will Miao 3c83e78d9f feat(ui): auto-newline after pasting URL in download and batch-import textareas
Extract auto-newline-on-paste logic into shared setupAutoNewlineOnPaste() utility in uiHelpers.js.
Apply it to both the Download modal (modelUrl) and Batch Import modal (batchUrlInput)
textarea, so users can paste multiple URLs in succession without manually pressing Enter.
2026-07-02 10:53:33 +08:00
Will Miao d7291f73c9 fix(download): recognize civitai.red and civitai.green URLs in batch download (#1003) 2026-07-02 10:28:03 +08:00
161 changed files with 34925 additions and 21726 deletions
+4
View File
@@ -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/
+1
View File
@@ -102,6 +102,7 @@ npm run test:coverage # Generate coverage report
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)`
- Event handlers via `addEventListener` or widget callbacks
- Shared utilities: `web/comfyui/utils.js`
- Dual-mode rendering patterns (canvas vs Vue): see `docs/comfyui-dual-mode-widgets.md`
### Vue Composables Pattern
+2 -2
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -15,6 +15,8 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.nodes.lora_pool import LoraPoolLM
from .py.nodes.lora_randomizer import LoraRandomizerLM
from .py.nodes.lora_cycler import LoraCyclerLM
from .py.nodes.lora_info import LoraInfoLM
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
from .py.metadata_collector import init as init_metadata_collector
except (
ImportError
@@ -56,6 +58,10 @@ except (
"py.nodes.lora_randomizer"
).LoraRandomizerLM
LoraCyclerLM = importlib.import_module("py.nodes.lora_cycler").LoraCyclerLM
LoraInfoLM = importlib.import_module("py.nodes.lora_info").LoraInfoLM
LoraSyntaxToPath = importlib.import_module(
"py.nodes.lora_syntax_to_path"
).LoraSyntaxToPath
init_metadata_collector = importlib.import_module("py.metadata_collector").init
NODE_CLASS_MAPPINGS = {
@@ -75,6 +81,8 @@ NODE_CLASS_MAPPINGS = {
LoraPoolLM.NAME: LoraPoolLM,
LoraRandomizerLM.NAME: LoraRandomizerLM,
LoraCyclerLM.NAME: LoraCyclerLM,
LoraInfoLM.NAME: LoraInfoLM,
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
}
WEB_DIRECTORY = "./web/comfyui"
+391 -362
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
# ComfyUI Dual-Mode Widget Rendering
ComfyUI custom node widgets render in one of two modes. Patterns that work in one often fail silently in the other. Test both.
## Mode Detection
```js
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode
```
In Vue SFCs, `window.LiteGraph` is unavailable — pass as a prop from `main.ts`.
## Canvas Mode Layout
Uses `computeLayoutSize()` + `distributeSpace()` to allocate widget height within the node. Widgets with `computeLayoutSize` participate in space distribution; those with `computeSize` have fixed height.
- `getMinHeight()` in `addDOMWidget` options → minimum widget height
- `widget.computeLayoutSize()``{ minHeight, minWidth, maxHeight? }`
- Avoid `getMaxHeight()` unless the widget genuinely needs a fixed cap (prevents user resize)
## Vue Mode Layout
Uses CSS Grid (`grid-template-rows`) + `ResizeObserver`. The ResizeObserver watches the widget's DOM and feeds back into grid row sizing. This creates a feedback loop: content grows → row resizes → more space for content → content reflows/grows → row resizes again.
### Height Containment
The fix: `contain: layout size` on the widget root. This tells the browser the element's intrinsic size is CSS-determined, not driven by descendant content. The ResizeObserver sees a stable size and the loop is broken.
```css
.widget-root.lm-vue-node {
height: 100%;
min-height: var(--comfy-widget-min-height, 200px);
contain: layout size;
}
```
Existing examples: `.lm-loras-container.lm-vue-node` and `.comfy-tags-container.lm-vue-node` in `web/comfyui/lm_styles.css`.
**Do NOT** fix height issues with `maxHeight`, `getMaxHeight()`, or inline `max-height` — these prevent the user from resizing the node.
## Scroll Wheel Isolation
Both modes need to distinguish "user wants to scroll widget content" from "user wants to zoom canvas".
**Canvas mode:** Add `@wheel` on widget root. Check `event.target.closest(selector)` for scrollable sub-areas. If scrollable → `event.stopPropagation()`. Otherwise → `app.canvas.processMouseWheel(event)`.
**Vue mode:** Add CSS class `lm-wheel-scrollable` to scrollable elements. The global capture-phase hook in `web/comfyui/utils.js` (`enableListWheelScroll`) detects wheel events on marked elements and manually scrolls them via `element.scrollTop`, consuming the event before canvas zoom sees it.
## DOM Structure
`main.ts` creates an outer `<div>` container, then `vueApp.mount(container)`. The Vue app renders its own root element inside.
- `container.id` / `container.style.*` → outer element
- Vue scoped `<style>``[data-v-hash]` applies only to Vue root
Classes needed by scoped Vue CSS must go on the Vue root element. Pass data as props and bind with `:class` rather than manipulating the DOM from `main.ts`.
## Serialization
For stateful widgets that need workflow persistence:
- `serialize: true` in `addDOMWidget` options
- `serializeValue()` → state snapshot (called on workflow save)
- `onSetValue(v)` → restore state (called on workflow load)
- Always handle missing keys in restored value for backward compatibility with old workflows
File diff suppressed because one or more lines are too long
+2202 -2168
View File
File diff suppressed because it is too large Load Diff
+46 -12
View File
@@ -233,7 +233,7 @@
"presetNamePlaceholder": "Preset name...",
"baseModel": "Base Model",
"baseModelSearchPlaceholder": "Search base models...",
"modelTags": "Tags (Top 20)",
"modelTags": "Tags",
"modelTypes": "Model Types",
"license": "License",
"noCreditRequired": "No Credit Required",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
"noCreditRequiredTooltip": "Use the model without crediting the creator",
"noTags": "No tags",
"tagSearchPlaceholder": "Search tags...",
"noTagMatches": "No tags match the current search.",
"autoTags": "Auto Tags",
"noBaseModelMatches": "No base models match the current search.",
"clearAll": "Clear All Filters",
@@ -505,7 +507,9 @@
"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"
"duplicatePath": "This path is already configured",
"checkpointUnetOverlap": "Cannot use the same path for both checkpoints and diffusion models: {paths}",
"checkpointUnetOverlapInline": "This path is also used for a different model type. Use separate folders for checkpoints and diffusion models."
}
},
"priorityTags": {
@@ -638,7 +642,13 @@
"preparing": "Preparing download...",
"connecting": "Connecting to download server...",
"completed": "Completed",
"downloadComplete": "Download completed successfully"
"downloadComplete": "Download completed successfully",
"enableCivarchiveApi": "Enable CivArchive API as metadata provider",
"enableCivarchiveApiHelp": "When on, CivArchive API is used as a fallback source for model metadata (e.g. for models deleted from CivitAI). Turn off to avoid CivArchive rate limits entirely.",
"providerOrder": "Metadata provider fallback order",
"providerOrderHelp": "CivitAI API is always tried first. Choose the order of the remaining providers when looking up metadata.",
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
},
"proxySettings": {
"enableProxy": "Enable App-level Proxy",
@@ -661,10 +671,18 @@
"aiProvider": {
"title": "AI Provider",
"provider": "Provider",
"providerHelp": "Choose your LLM provider. OpenAI and Ollama use preset API endpoints. Custom lets you specify any OpenAI-compatible endpoint.",
"custom": "Custom (OpenAI-compatible)",
"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 (e.g. https://api.openai.com/v1). Leave empty to use the provider default.",
"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.",
@@ -673,7 +691,8 @@
"apiKeyConfigured": "Configured",
"apiKeySet": "Set up",
"model": "Model",
"modelHelp": "The model name to use (e.g. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Check your provider for available models."
"modelHelp": "The model to use. Select from the dropdown (fetched from your provider) or type a custom model name.",
"modelPlaceholder": "Select a model..."
}
},
"loras": {
@@ -772,12 +791,14 @@
"complete": "Auto-organize complete",
"error": "Error: {error}"
},
"enrichHfAgent": "Enrich Metadata (Agent)"
"enrichHfAgent": "Enrich HF Metadata (AI)"
},
"contextMenu": {
"refreshMetadata": "Refresh Civitai Data",
"checkUpdates": "Check Updates",
"relinkCivitai": "Re-link to Civitai",
"linkModel": "Link Model",
"linkCivitai": "Link to Civitai",
"linkHuggingFace": "Link to HuggingFace",
"copySyntax": "Copy LoRA Syntax",
"copyFilename": "Copy Model Filename",
"copyRecipeSyntax": "Copy Recipe Syntax",
@@ -797,7 +818,7 @@
"viewAllLoras": "View All LoRAs",
"downloadMissingLoras": "Download Missing LoRAs",
"deleteRecipe": "Delete Recipe",
"enrichHfAgent": "Enrich Metadata (Agent)"
"enrichHfAgent": "Enrich HF Metadata (AI)"
}
},
"recipes": {
@@ -1194,7 +1215,9 @@
"preparing": "Preparing download...",
"downloadedPreview": "Downloaded preview image",
"downloadingFile": "Downloading {type} file",
"finalizing": "Finalizing download..."
"finalizing": "Finalizing download...",
"cancelling": "Cancelling download...",
"cancelled": "Download cancelled"
},
"progress": {
"currentFile": "Current file:",
@@ -1310,6 +1333,14 @@
"pathPlaceholder": "Type folder path or select from tree below...",
"root": "Root"
},
"linkHuggingFace": {
"title": "Link to HuggingFace",
"infoText": "Paste the HuggingFace repository URL to associate this model with its source. This enables AI-powered metadata enrichment.",
"urlLabel": "HuggingFace Repository URL:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Enter the full URL of the HuggingFace repository.",
"confirmAction": "Save & Link"
},
"relinkCivitai": {
"title": "Re-link to Civitai",
"warning": "Warning:",
@@ -1994,7 +2025,8 @@
"imagesCompleted": "Example images {action} completed",
"imagesFailed": "Example images {action} failed",
"loadError": "Error loading downloads: {message}",
"downloadError": "Download error: {message}"
"downloadError": "Download error: {message}",
"downloadStopped": "Download cancelled"
},
"import": {
"folderTreeFailed": "Failed to load folder tree",
@@ -2039,6 +2071,8 @@
"contentRatingFailed": "Failed to set content rating: {message}",
"relinkSuccess": "Model successfully re-linked to Civitai",
"relinkFailed": "Error: {message}",
"linkHfSuccess": "Model successfully linked to HuggingFace",
"linkHfFailed": "Error: {message}",
"fetchMetadataFirst": "Please fetch metadata from CivitAI first",
"noCivitaiInfo": "No CivitAI information available",
"missingHash": "Model hash not available"
+2202 -2168
View File
File diff suppressed because it is too large Load Diff
+2202 -2168
View File
File diff suppressed because it is too large Load Diff
+2202 -2168
View File
File diff suppressed because it is too large Load Diff
+2202 -2168
View File
File diff suppressed because it is too large Load Diff
+2202 -2168
View File
File diff suppressed because it is too large Load Diff
+2202 -2168
View File
File diff suppressed because it is too large Load Diff
+2202 -2168
View File
File diff suppressed because it is too large Load Diff
+2202 -2168
View File
File diff suppressed because it is too large Load Diff
+49 -7
View File
@@ -177,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()
@@ -193,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
@@ -209,6 +208,12 @@ class Config:
if not isinstance(library_config, dict):
return
# Always read recipes_path — it is independent of extra folder paths
# and must be set before any early returns below.
recipes_path = library_config.get("recipes_path", "")
if isinstance(recipes_path, str) and recipes_path:
self.recipes_path = recipes_path
extra_folder_paths = library_config.get("extra_folder_paths")
if not isinstance(extra_folder_paths, dict):
return
@@ -234,10 +239,6 @@ class Config:
extra_embedding
)
recipes_path = library_config.get("recipes_path", "")
if isinstance(recipes_path, str) and recipes_path:
self.recipes_path = recipes_path
if self.extra_loras_roots:
logger.info(
"Found extra LoRA roots:"
@@ -358,6 +359,47 @@ class Config:
"Failed to rename legacy 'default' library: %s", rename_error
)
# Clean up a stale "default" library entry that has no meaningful
# paths configured (e.g. leftover bootstrap artifact). This only
# fires when "comfyui" already exists so we never delete the last
# remaining library.
if (
"default" in libraries
and "comfyui" in libraries
and isinstance(default_library, Mapping)
):
default_folder_paths = _normalize_library_folder_paths(
default_library
)
default_extra_paths = default_library.get("extra_folder_paths", {})
has_meaningful_paths = bool(default_folder_paths) or bool(
default_extra_paths
) or any(
default_library.get(key)
for key in (
"default_lora_root",
"default_checkpoint_root",
"default_unet_root",
"default_embedding_root",
"recipes_path",
)
)
if not has_meaningful_paths:
try:
settings_service.delete_library("default")
libraries_changed = True
logger.info(
"Removed stale 'default' library entry "
"with no meaningful paths configured"
)
libraries = settings_service.get_libraries()
comfy_library = libraries.get("comfyui", {})
except Exception as delete_error:
logger.debug(
"Failed to remove stale 'default' library: %s",
delete_error,
)
default_lora_root = _resolve_valid_default_root(
comfy_library.get("default_lora_root", ""),
list(self.loras_roots or []),
+4
View File
@@ -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()
@@ -1,4 +1,4 @@
"""Agent CLI — thin in-process wrappers around LoRA Manager internal services.
"""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
@@ -7,15 +7,15 @@ risk of double import or circular dependencies.
Usage (in-process, primary)::
from py.agent_cli import list_base_models, read_metadata
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.agent_cli base-models list
python -m py.agent_cli metadata read /path/to/model.safetensors
python -m py.metadata_ops base-models list
python -m py.metadata_ops metadata read /path/to/model.safetensors
"""
from __future__ import annotations
@@ -32,23 +32,26 @@ logger = logging.getLogger(__name__)
# Helpers
# ---------------------------------------------------------------------------
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_TYPE_MAP: dict[str, str] = {
"get_lora_scanner": "lora",
"get_checkpoint_scanner": "checkpoint",
"get_embedding_scanner": "embedding",
}
Iterates all known scanner types and returns the first one whose cache
contains the given path. Returns ``(None, None)`` when no scanner
claims the model.
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 (
"get_lora_scanner",
"get_checkpoint_scanner",
"get_embedding_scanner",
):
for getter_name in SCANNER_GETTER_NAMES:
getter = getattr(ServiceRegistry, getter_name, None)
if getter is None:
continue
@@ -59,15 +62,31 @@ async def _find_scanner_for_model(
cache = await scanner.get_cached_data()
for entry in cache.raw_data:
if os.path.normpath(entry.get("file_path", "")) == normalized:
return scanner, entry
return scanner, entry, getter_name
except Exception as exc:
logger.debug(
"Scanner %s check failed for %s: %s",
getter_name,
model_path,
exc,
getter_name, model_path, exc,
)
return None, None
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"
# ---------------------------------------------------------------------------
@@ -76,38 +95,29 @@ async def _find_scanner_for_model(
async def list_base_models(limit: int = 0) -> List[str]:
"""Return deduplicated base model names from all model caches.
"""Return all valid CivitAI base model names.
The result is ordered by frequency (most common first). Pass
*limit* = 0 (default) for all models.
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.service_registry import ServiceRegistry
from ..services.civitai_base_model_service import (
CivitaiBaseModelService,
)
counts: Dict[str, int] = {}
for getter_name in (
"get_lora_scanner",
"get_checkpoint_scanner",
"get_embedding_scanner",
):
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:
bm = entry.get("base_model")
if bm:
counts[bm] = counts.get(bm, 0) + 1
except Exception as exc:
logger.debug("list_base_models scanner %s error: %s", getter_name, exc)
sorted_names = [name for name, _ in sorted(counts.items(), key=lambda x: -x[1])]
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 sorted_names[:limit]
return sorted_names
return names[:limit]
return names
async def read_metadata(model_path: str) -> Dict[str, Any]:
@@ -153,17 +163,17 @@ async def download_preview(
*,
target_width: int = 480,
quality: int = 85,
) -> bool:
) -> 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 ``True`` on success.
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 False
return None
base_name = os.path.splitext(os.path.basename(model_path))[0]
preview_dir = os.path.dirname(model_path)
@@ -186,8 +196,7 @@ async def download_preview(
)
with open(output_path, "wb") as f:
f.write(optimized_data)
logger.info("Preview downloaded and optimised for %s", model_path)
return True
return output_path
except Exception as exc:
logger.warning("Preview optimisation failed, saving raw: %s", exc)
# Fall through to raw save
@@ -196,12 +205,11 @@ async def download_preview(
try:
ok, _ = await downloader.download_file(url, output_path, use_auth=False)
if ok:
logger.info("Preview downloaded (fallback) for %s", model_path)
return True
return output_path
except Exception as exc:
logger.warning("Preview fallback download failed for %s: %s", model_path, exc)
return False
return None
async def refresh_cache(model_path: str) -> bool:
@@ -1,17 +1,12 @@
"""Subprocess entry point for AgentCLI (debugging / external use).
"""Subprocess entry point for ``metadata_ops`` (debugging / external use).
Usage::
python -m py.agent_cli base-models list [--limit N]
python -m py.agent_cli metadata read <path>
python -m py.agent_cli metadata update <path> --json '{...}'
python -m py.agent_cli preview download <path> --url <url>
python -m py.agent_cli cache refresh <path>
NOTE: This is an **optional** convenience wrapper. The primary consumer of
AgentCLI is the :mod:`AgentService` (in-process). This entry point exists
for manual debugging and future integration with subprocess-based agent
frameworks.
python -m py.metadata_ops base-models list [--limit N]
python -m py.metadata_ops metadata read <path>
python -m py.metadata_ops metadata update <path> --json '{...}'
python -m py.metadata_ops preview download <path> --url <url>
python -m py.metadata_ops cache refresh <path>
"""
from __future__ import annotations
+6 -1
View File
@@ -41,7 +41,12 @@ async def api_json_error(
if exc.status < 400:
raise
logger.warning(
# Preview 404 is routine (file deleted from disk) — not worth a warning.
logger_method = logger.warning
if request.path.startswith("/api/lm/previews") and exc.status == 404:
logger_method = logger.debug
logger_method(
"API %s %s returned HTTP %d: %s",
request.method,
request.path,
+45
View File
@@ -0,0 +1,45 @@
"""Lora Info display node — pure frontend node for showing selected LoRA info.
This node does NOT participate in workflow execution. Its single optional
"lora_source" input exists solely as a wire-connection anchor so that the
frontend can traverse the graph and push selection data to connected info nodes.
"""
from __future__ import annotations
class LoraInfoLM:
"""Display node that shows filename and notes for the selected LoRA."""
NAME = "Lora Info (LoraManager)"
CATEGORY = "Lora Manager/utils"
DESCRIPTION = (
"Displays information (filename, notes) about the currently selected "
"LoRA. Connect any output from a LoRA Loader or Stacker to the "
"lora_source input, then select a LoRA in the source widget — the "
"info updates automatically. Does not affect workflow execution."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
}
RETURN_TYPES = ()
RETURN_NAMES = ()
OUTPUT_NODE = False
FUNCTION = "noop"
def noop(self, **kwargs):
# This node is display-only — no workflow execution needed.
return ()
NODE_CLASS_MAPPINGS = {
LoraInfoLM.NAME: LoraInfoLM,
}
NODE_DISPLAY_NAME_MAPPINGS = {
LoraInfoLM.NAME: "Lora Info (LoraManager)",
}
+2 -17
View File
@@ -1,6 +1,5 @@
import importlib
import logging
import re
import comfy.sd # type: ignore
import comfy.utils # type: ignore
@@ -14,6 +13,7 @@ from .utils import (
extract_lora_name,
get_loras_list,
nunchaku_load_lora,
parse_lora_syntax,
)
logger = logging.getLogger(__name__)
@@ -189,25 +189,10 @@ class LoraTextLoaderLM:
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
FUNCTION = "load_loras_from_text"
def parse_lora_syntax(self, text):
"""Parse LoRA syntax from text input."""
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
matches = re.findall(pattern, text, re.IGNORECASE)
loras = []
for match in matches:
model_strength = float(match[1])
loras.append({
"name": match[0],
"model_strength": model_strength,
"clip_strength": float(match[2]) if match[2] else model_strength,
})
return loras
def load_loras_from_text(self, model, lora_syntax, clip=None, lora_stack=None):
"""Load LoRAs based on text syntax input."""
lora_entries = _collect_stack_entries(lora_stack)
for lora in self.parse_lora_syntax(lora_syntax):
for lora in parse_lora_syntax(lora_syntax):
lora_path, trigger_words = get_lora_info_absolute(lora["name"])
lora_entries.append({
"name": lora["name"],
+62
View File
@@ -0,0 +1,62 @@
"""Node to resolve `<lora:name:strength>` syntax to absolute file system paths.
Takes the loaded_loras / active_loras STRING output from LoraLoaderLM or
LoraStackerLM and resolves each lora name to its absolute path on disk via
the scanner cache. Unknown names are returned as-is.
"""
import logging
from ..utils.utils import get_lora_info_absolute
from .utils import parse_lora_syntax
logger = logging.getLogger(__name__)
class LoraSyntaxToPath:
NAME = "LoRA Syntax → Path (LoraManager)"
CATEGORY = "Lora Manager/utils"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_syntax": (
"STRING",
{
"forceInput": True,
"multiline": True,
"tooltip": (
"<lora:name:strength> formatted text from "
"loaded_loras / active_loras output"
),
},
),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("paths",)
FUNCTION = "resolve"
def resolve(self, lora_syntax: str) -> tuple[str]:
"""Parse <lora:...> syntax and resolve each name to its absolute path."""
if not lora_syntax or not lora_syntax.strip():
logger.info("Received empty lora_syntax input")
return ("",)
parsed = parse_lora_syntax(lora_syntax)
if not parsed:
logger.info("No valid <lora:...> entries found in input")
return ("",)
paths: list[str] = []
for entry in parsed:
try:
absolute_path, _ = get_lora_info_absolute(entry["name"])
paths.append(absolute_path)
except Exception:
logger.warning("Failed to resolve lora '%s', skipping", entry["name"])
continue
return ("\n".join(paths),)
+20
View File
@@ -36,6 +36,7 @@ any_type = AnyType("*")
# Common methods extracted from lora_loader.py and lora_stacker.py
import os
import re
import logging
import copy
import sys
@@ -69,6 +70,25 @@ def extract_lora_name(lora_path):
return apply_lora_syntax_format(name_no_ext)
def parse_lora_syntax(text: str) -> list[dict]:
"""Parse <lora:name:strength> syntax from text input into a list of dicts.
Each entry contains: name, model_strength, clip_strength.
Supports both ``<lora:name:strength>`` and ``<lora:name:model_strength:clip_strength>``.
"""
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
matches = re.findall(pattern, text, re.IGNORECASE)
loras = []
for match in matches:
model_strength = float(match[1])
loras.append({
"name": match[0],
"model_strength": model_strength,
"clip_strength": float(match[2]) if match[2] else model_strength,
})
return loras
def get_loras_list(kwargs):
"""Helper to extract loras list from either old or new kwargs format"""
if "loras" not in kwargs:
+9 -11
View File
@@ -60,21 +60,21 @@ class AgentHandler:
skill_name = request.match_info.get("skill_name", "")
if not skill_name:
return web.json_response(
{"error": "Skill name is required"}, status_code=400
{"error": "Skill name is required"}, status=400
)
try:
body = await request.json()
except Exception:
return web.json_response(
{"error": "Invalid JSON body"}, status_code=400
{"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_code=400,
status=400,
)
service = await self._ensure_service()
@@ -99,12 +99,11 @@ class AgentHandler:
# Launch execution in the background
progress_reporter = AgentProgressReporter()
logger.info(
"Agent skill '%s' starting for %d model(s) in background task",
"LLM enrichment '%s' starting for %d model(s)",
skill_name, len(model_paths),
)
async def _run() -> None:
logger.info("_run background task started for skill '%s'", skill_name)
try:
result = await service.execute_skill(
skill_name=skill_name,
@@ -112,11 +111,11 @@ class AgentHandler:
progress_callback=progress_reporter,
)
logger.info(
"Agent skill '%s' finished: success=%s, summary='%s', errors=%s",
"LLM enrichment '%s' finished: success=%s, summary='%s', errors=%s",
skill_name, result.success, result.summary, result.errors,
)
except LLMNotConfiguredError as exc:
logger.warning("Agent skill '%s' not configured: %s", skill_name, exc)
logger.warning("LLM enrichment '%s' not configured: %s", skill_name, exc)
await progress_reporter.on_progress(
{
"type": "agent_progress",
@@ -126,7 +125,7 @@ class AgentHandler:
}
)
except Exception as exc:
logger.error("Agent skill '%s' failed: %s", skill_name, exc, exc_info=True)
logger.error("LLM enrichment '%s' failed: %s", skill_name, exc, exc_info=True)
await progress_reporter.on_progress(
{
"type": "agent_progress",
@@ -137,8 +136,7 @@ class AgentHandler:
)
# Fire and forget — progress comes via WebSocket
task = asyncio.create_task(_run())
logger.info("Agent skill '%s' background task created (id=%s)", skill_name, task)
asyncio.create_task(_run())
return web.json_response(
{
@@ -163,5 +161,5 @@ class AgentHandler:
# TODO: implement cooperative cancellation in AgentService
return web.json_response(
{"status": "acknowledged", "note": "Cancellation not yet implemented"},
status_code=200,
status=200,
)
+130 -39
View File
@@ -122,8 +122,12 @@ async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
metadata._unknown_fields["hf_url"] = hf_url
metadata.from_civitai = False # HF models are not from CivitAI
metadata_dict = metadata.to_dict()
if "trainedWords" in metadata_dict and not metadata_dict["trainedWords"]:
del metadata_dict["trainedWords"]
# 3. Save metadata atomically
await MetadataManager.save_metadata(dest_path, metadata)
await MetadataManager.save_metadata(dest_path, metadata_dict)
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
# 4. Determine relative folder path for cache
@@ -147,9 +151,117 @@ async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
logger.warning("Failed to save HF metadata for %s: %s", dest_path, exc)
def _find_matching_root(dest_dir: str) -> str | None:
"""Walk up *dest_dir* to find which configured scanner root it belongs to."""
norm = os.path.normpath(dest_dir).replace(os.sep, "/")
all_roots = []
for root_list in (
config.loras_roots or [],
config.extra_loras_roots or [],
config.checkpoints_roots or [],
config.extra_checkpoints_roots or [],
config.unet_roots or [],
config.extra_unet_roots or [],
config.embeddings_roots or [],
config.extra_embeddings_roots or [],
):
all_roots.extend([os.path.normpath(p).replace(os.sep, "/") for p in root_list])
# Find the longest matching prefix
match: str | None = None
for root in all_roots:
if norm.startswith(root):
if match is None or len(root) > len(match):
match = root
return match
async def _add_to_scanner_cache(dest_path: str, metadata: dict[str, Any]) -> None:
model_dir = os.path.dirname(dest_path)
model_root = _find_matching_root(model_dir)
if not model_root:
raise ValueError(f"File path {dest_path} is not within any configured scanner root")
scanner_getter_name = _infer_model_type(model_root)[1]
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
if scanner_getter is None:
raise RuntimeError(f"Scanner getter '{scanner_getter_name}' not found in ServiceRegistry")
scanner = await scanner_getter()
if scanner is None:
raise RuntimeError(f"Scanner '{scanner_getter_name}' returned None")
await scanner.update_single_model_cache(dest_path, dest_path, metadata)
class HfHandler:
"""Handle Hugging Face model browsing and download."""
async def set_hf_url(self, request: web.Request) -> web.Response:
try:
payload: dict[str, Any] = await request.json()
except json.JSONDecodeError:
return web.json_response({"success": False, "error": "Invalid JSON"}, status=400)
file_path = (payload.get("file_path") or "").strip()
hf_url = (payload.get("hf_url") or "").strip()
if not file_path or not hf_url:
return web.json_response(
{"success": False, "error": "Missing required fields: 'file_path' and 'hf_url'"},
status=400,
)
m = re.match(r"^https?://huggingface\.co/([^/]+/[^/]+)/?$", hf_url)
if not m:
return web.json_response(
{
"success": False,
"error": "Invalid HuggingFace URL. Expected format: https://huggingface.co/user/repo",
},
status=400,
)
if not os.path.isfile(file_path):
return web.json_response(
{"success": False, "error": f"File not found: {file_path}"},
status=404,
)
model_root = _find_matching_root(os.path.dirname(file_path))
if not model_root:
return web.json_response(
{
"success": False,
"error": "File is not within any configured model directory. Cannot link to HuggingFace.",
},
status=400,
)
try:
existing = await MetadataManager.load_metadata_payload(file_path)
if existing.get("hf_url") == hf_url:
return web.json_response({
"success": True,
"message": "hf_url already set",
"hf_url": hf_url,
})
existing["hf_url"] = hf_url
existing["from_civitai"] = False
await MetadataManager.save_metadata(file_path, existing)
await _add_to_scanner_cache(file_path, existing)
logger.info("Set hf_url=%s for %s", hf_url, file_path)
return web.json_response({
"success": True,
"message": f"hf_url set to {hf_url}",
"hf_url": hf_url,
})
except Exception as exc:
logger.error("Failed to set hf_url for %s: %s", file_path, exc)
return web.json_response(
{"success": False, "error": str(exc)},
status=500,
)
async def get_hf_repo_files(self, request: web.Request) -> web.Response:
"""List model-weight files from a HF repo with real file sizes.
@@ -251,8 +363,8 @@ class HfHandler:
if ".." in (author, repo_name) or "." in (author, repo_name):
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
# Validate filename — must not contain path separators or ..
if "/" in filename or "\\" in filename or ".." in filename:
# Validate filename — must not contain path traversal
if ".." in filename:
return web.json_response({"error": "Invalid filename"}, status=400)
# Validate relative_path — must not be absolute or escape base directory
@@ -262,35 +374,17 @@ class HfHandler:
if ".." in relative_path.split("/") or "\\" in relative_path:
return web.json_response({"error": "Invalid relative_path"}, status=400)
# Validate model_root — must not contain path traversal
if not os.path.isabs(model_root):
# For relative model_root, check it doesn't escape
resolved_model_root = os.path.realpath(
os.path.join(os.getcwd(), "models", model_root)
)
# Use model_root directly as the base directory — same approach as
# CivitAI's download path (download_manager.py). No realpath, no
# allowed-roots validation, no path-traversal check; those are
# unnecessary when the frontend sends the path from its own dropdown
# (populated from scanner roots). Using the "business path" directly
# keeps dest_path consistent with scanner roots so that later folder
# derivation (in _save_hf_metadata) works correctly.
if os.path.isabs(model_root):
base_dir = os.path.normpath(model_root)
else:
resolved_model_root = os.path.realpath(model_root)
# Verify model_root is within a configured scanner root
allowed_roots = set()
for root_list in (
config.loras_roots or [],
config.extra_loras_roots or [],
config.checkpoints_roots or [],
config.extra_checkpoints_roots or [],
config.unet_roots or [],
config.extra_unet_roots or [],
config.embeddings_roots or [],
config.extra_embeddings_roots or [],
):
for r in root_list:
allowed_roots.add(os.path.realpath(r))
if not any(resolved_model_root == root or resolved_model_root.startswith(root + os.sep) for root in allowed_roots):
logger.warning("Invalid model_root rejected: %s", model_root)
return web.json_response({"error": f"Invalid model_root: {model_root}"}, status=400)
base_dir = resolved_model_root
base_dir = os.path.normpath(os.path.join(os.getcwd(), "models", model_root))
if use_default_paths:
target_dir = os.path.join(base_dir, "huggingface", author, repo_name)
@@ -299,15 +393,12 @@ class HfHandler:
else:
target_dir = base_dir
os.makedirs(target_dir, exist_ok=True)
dest_path = os.path.join(target_dir, filename)
# Strip HF repo subdirectory — "diffusion_models/xxx.safetensors"
# is an HF repo convention, not meaningful for local storage.
file_base = os.path.basename(filename)
# Resolve symlinks and check for path traversal escape
real_dest = os.path.realpath(dest_path)
real_base = os.path.realpath(target_dir)
if not real_dest.startswith(real_base + os.sep):
logger.warning("Path traversal blocked: %s -> %s", dest_path, real_dest)
return web.json_response({"error": "Path traversal detected"}, status=400)
os.makedirs(target_dir, exist_ok=True)
dest_path = os.path.join(target_dir, file_base)
# Check if already exists (simple skip)
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
+424 -35
View File
@@ -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 (
@@ -567,12 +573,18 @@ class NodeRegistry:
tab_nodes[nd["unique_id"]] = nd
async with self._lock:
prev_count = len(self._tab_nodes.get(sid, {}))
self._tab_nodes[sid] = tab_nodes
self._waiting_clients.discard(sid)
if not self._waiting_clients:
self._ready.set()
total_tabs = len(self._tab_nodes)
logger.debug("Registered %s nodes from client %s", len(nodes), sid)
if len(nodes) != prev_count or len(nodes) > 0:
logger.debug(
"[LM:Registry] stored %s nodes (was %s) for client %s (total tabs: %s)",
len(nodes), prev_count, sid, total_tabs,
)
def prepare_for_refresh(self, active_sids: list[str]) -> None:
"""Set the list of client IDs we expect to hear from during the next refresh cycle."""
@@ -595,10 +607,17 @@ class NodeRegistry:
longer connected."""
async with self._lock:
# Garbage-collect stale entries (disconnected tabs)
stale_sids = []
if active_sids is not None:
for sid in list(self._tab_nodes):
if sid not in active_sids:
stale_sids.append(sid)
del self._tab_nodes[sid]
if stale_sids:
logger.debug(
"[LM:Registry] GC pruned %s disconnected tabs: %s",
len(stale_sids), stale_sids,
)
merged: dict[str, dict] = {}
tab_info: dict[str, dict] = {}
@@ -1400,8 +1419,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",
}
)
@@ -1459,6 +1479,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
@@ -1548,7 +1570,11 @@ class SettingsHandler:
else:
self._settings.set(key, value)
if key == "enable_metadata_archive_db":
if key in (
"enable_metadata_archive_db",
"enable_civarchive_api",
"metadata_provider_order",
):
await self._metadata_provider_updater()
if key in self._PROXY_KEYS:
@@ -1563,6 +1589,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}"
@@ -1585,6 +1647,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:
@@ -1712,6 +1788,124 @@ class LoraCodeHandler:
logger.error("Failed to update lora code: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_update_lora_code(self, request: web.Request) -> web.Response:
"""GET version of update_lora_code — reads parameters from query string.
Query params:
lora_code (required) the LoRA syntax to send
mode (optional) "append" (default) or "replace"
node_id (repeatable) target node id(s), e.g. node_id=3&node_id=5
node_ids (optional) JSON-encoded array for complex references with graph_id:
[{"node_id":3,"graph_id":"g1"}, ...]
"""
try:
node_ids_raw = request.query.get("node_ids")
node_id_list = request.query.getall("node_id", [])
lora_code = request.query.get("lora_code", "")
mode = request.query.get("mode", "append")
if not lora_code:
return web.json_response(
{"success": False, "error": "Missing lora_code parameter"},
status=400,
)
node_ids = None
if node_ids_raw:
try:
node_ids = json.loads(node_ids_raw)
except (json.JSONDecodeError, TypeError):
return web.json_response(
{"success": False, "error": "node_ids must be a valid JSON array"},
status=400,
)
if not isinstance(node_ids, list) or not node_ids:
return web.json_response(
{"success": False, "error": "node_ids must be a non-empty JSON array"},
status=400,
)
elif node_id_list:
node_ids = node_id_list
results = []
if node_ids is None:
try:
self._prompt_server.instance.send_sync(
"lora_code_update",
{"id": -1, "lora_code": lora_code, "mode": mode},
)
results.append({"node_id": "broadcast", "success": True})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error broadcasting lora code: %s", exc)
results.append(
{"node_id": "broadcast", "success": False, "error": str(exc)}
)
else:
for entry in node_ids:
node_identifier = entry
graph_identifier = None
if isinstance(entry, dict):
node_identifier = entry.get("node_id")
graph_identifier = entry.get("graph_id")
if node_identifier is None:
results.append(
{
"node_id": node_identifier,
"graph_id": graph_identifier,
"success": False,
"error": "Missing node_id parameter",
}
)
continue
try:
parsed_node_id = int(node_identifier)
except (TypeError, ValueError):
parsed_node_id = node_identifier
payload = {
"id": parsed_node_id,
"lora_code": lora_code,
"mode": mode,
}
if graph_identifier is not None:
payload["graph_id"] = str(graph_identifier)
try:
self._prompt_server.instance.send_sync(
"lora_code_update",
payload,
)
results.append(
{
"node_id": parsed_node_id,
"graph_id": payload.get("graph_id"),
"success": True,
}
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error sending lora code to node %s (graph %s): %s",
parsed_node_id,
graph_identifier,
exc,
)
results.append(
{
"node_id": parsed_node_id,
"graph_id": payload.get("graph_id"),
"success": False,
"error": str(exc),
}
)
return web.json_response({"success": True, "results": results})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to update lora code (GET): %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class TrainedWordsHandler:
async def get_trained_words(self, request: web.Request) -> web.Response:
@@ -3057,6 +3251,8 @@ class NodeRegistryHandler:
self._node_registry = node_registry
self._prompt_server = prompt_server
self._standalone_mode = standalone_mode
self._refresh_lock = asyncio.Lock()
self._last_slow_path_ts: float = 0.0
async def register_nodes(self, request: web.Request) -> web.Response:
try:
@@ -3103,7 +3299,12 @@ class NodeRegistryHandler:
)
graph_name = node.get("graph_name")
try:
node["node_id"] = int(node_id)
# Handle compound node IDs from expanded group subgraphs,
# e.g. "252:0" → 0 (parent scope is already in graph_id)
if isinstance(node_id, str) and ":" in node_id:
node["node_id"] = int(node_id.rsplit(":", 1)[-1])
else:
node["node_id"] = int(node_id)
except (TypeError, ValueError):
return web.json_response(
{
@@ -3144,42 +3345,101 @@ class NodeRegistryHandler:
status=503,
)
# Snapshot of currently-connected ComfyUI tabs
active_sids = list(self._prompt_server.instance.sockets.keys())
self._node_registry.prepare_for_refresh(active_sids)
try:
self._prompt_server.instance.send_sync("lora_registry_refresh", {})
logger.debug(
"Sent registry refresh request (expecting %s clients)", len(active_sids)
)
except Exception as exc:
logger.error("Failed to send registry refresh message: %s", exc)
return web.json_response(
{
"success": False,
"error": "Communication Error",
"message": f"Failed to communicate with ComfyUI frontend: {exc}",
},
status=500,
)
if not await self._node_registry.wait_for_all(timeout=2.0):
logger.warning(
"Registry refresh timeout after 2s (%s/%s clients responded)",
len(active_sids) - self._node_registry.pending_client_count,
len(active_sids),
)
# Re-read current sockets after the wait: a tab may have connected
# while we were waiting, and we don't want to garbage-collect it.
current_sids = set(self._prompt_server.instance.sockets.keys())
# Fast path: if the frontend has already pushed node data (via
# afterConfigureGraph / graphChanged hooks), return it immediately
# without triggering a WebSocket round-trip.
registry_info = await self._node_registry.get_merged_registry(
active_sids=current_sids
)
if registry_info["tab_count"] > 0:
logger.debug(
"[LM:Registry] fast path: %s nodes across %s tabs %s",
registry_info["node_count"],
registry_info["tab_count"],
dict(registry_info.get("tabs", {})),
)
return web.json_response({"success": True, "data": registry_info})
# Slow path: registry is empty — trigger refresh via WebSocket.
# Serialize with an async lock so concurrent callers don't all
# trigger separate WS refresh cycles. The second caller will
# re-check the fast path and (usually) find populated data.
async with self._refresh_lock:
# Re-check after acquiring the lock — another concurrent call
# may have populated the cache while we were waiting.
registry_info = await self._node_registry.get_merged_registry(
active_sids=current_sids
)
if registry_info["tab_count"] > 0:
logger.debug(
"[LM:Registry] fast path after lock wait: %s nodes across %s tabs",
registry_info["node_count"],
registry_info["tab_count"],
)
return web.json_response({"success": True, "data": registry_info})
# Cooldown: if the slow path ran recently (< 2 s) and
# returned empty, skip another WS round-trip.
elapsed = time.monotonic() - self._last_slow_path_ts
if elapsed < 2.0:
logger.debug(
"[LM:Registry] slow path cooldown (%.1fs since last refresh), returning empty",
elapsed,
)
return web.json_response(
{
"success": False,
"error": "Empty Registry",
"message": "No workflow nodes found — ensure ComfyUI is open and the extension is loaded.",
},
status=408,
)
logger.debug(
"[LM:Registry] slow path: cache empty, triggering WS refresh (%s connected tabs: %s)",
len(current_sids), list(current_sids)[:5],
)
active_sids = list(current_sids)
self._node_registry.prepare_for_refresh(active_sids)
try:
self._prompt_server.instance.send_sync("lora_registry_refresh", {})
logger.debug(
"Sent registry refresh request (expecting %s clients)", len(active_sids)
)
except Exception as exc:
logger.error("Failed to send registry refresh message: %s", exc)
return web.json_response(
{
"success": False,
"error": "Communication Error",
"message": f"Failed to communicate with ComfyUI frontend: {exc}",
},
status=500,
)
if not await self._node_registry.wait_for_all(timeout=0.5):
logger.warning(
"Registry refresh timeout after 0.5s (%s/%s clients responded)",
len(active_sids) - self._node_registry.pending_client_count,
len(active_sids),
)
# Re-read current sockets after the wait: a tab may have connected
# while we were waiting, and we don't want to garbage-collect it.
current_sids = set(self._prompt_server.instance.sockets.keys())
registry_info = await self._node_registry.get_merged_registry(
active_sids=current_sids
)
self._last_slow_path_ts = time.monotonic()
if registry_info["node_count"] == 0:
logger.warning("No nodes registered after refresh")
logger.debug(
"[LM:Registry] refresh OK — %s connected tab(s) but 0 compatible nodes found",
registry_info["tab_count"],
)
return web.json_response(
{
"success": False,
@@ -3215,7 +3475,7 @@ class NodeRegistryHandler:
status=400,
)
if not isinstance(value, str) or not value:
if value is None or (isinstance(value, str) and not value):
return web.json_response(
{"success": False, "error": "Missing value parameter"}, status=400
)
@@ -3293,6 +3553,130 @@ class NodeRegistryHandler:
logger.error("Failed to update node widget: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_update_node_widget(self, request: web.Request) -> web.Response:
"""GET version of update_node_widget — reads parameters from query string.
Query params:
widget_name (optional) the widget name to update (required unless action is set)
action (optional) alternative action, e.g. "inject_text" (required unless widget_name is set)
value (required) the value to set
mode (optional) "replace" (default) or "append"
node_id (repeatable) target node id(s), e.g. node_id=3&node_id=5
node_ids (optional) JSON-encoded array for complex references:
[{"node_id":3,"graph_id":"g1"}, ...]
"""
try:
widget_name = request.query.get("widget_name")
action = request.query.get("action")
value = request.query.get("value")
mode = request.query.get("mode", "replace")
node_ids_raw = request.query.get("node_ids")
node_id_list = request.query.getall("node_id", [])
if not action and (not isinstance(widget_name, str) or not widget_name):
return web.json_response(
{
"success": False,
"error": "Missing parameter: provide either 'action' or 'widget_name'",
},
status=400,
)
if value is None or (isinstance(value, str) and not value):
return web.json_response(
{"success": False, "error": "Missing value parameter"}, status=400
)
node_ids = None
if node_ids_raw:
try:
node_ids = json.loads(node_ids_raw)
except (json.JSONDecodeError, TypeError):
return web.json_response(
{"success": False, "error": "node_ids must be a valid JSON array"},
status=400,
)
if not isinstance(node_ids, list) or not node_ids:
return web.json_response(
{"success": False, "error": "node_ids must be a non-empty JSON array"},
status=400,
)
elif node_id_list:
node_ids = node_id_list
if not isinstance(node_ids, list) or not node_ids:
return web.json_response(
{"success": False, "error": "node_ids must be a non-empty list"},
status=400,
)
results = []
for entry in node_ids:
node_identifier = entry
graph_identifier = None
if isinstance(entry, dict):
node_identifier = entry.get("node_id")
graph_identifier = entry.get("graph_id")
if node_identifier is None:
results.append(
{
"node_id": node_identifier,
"graph_id": graph_identifier,
"success": False,
"error": "Missing node_id parameter",
}
)
continue
try:
parsed_node_id = int(node_identifier)
except (TypeError, ValueError):
parsed_node_id = node_identifier
payload: dict = {
"id": parsed_node_id,
"value": value,
"mode": mode,
}
if action:
payload["action"] = action
if widget_name:
payload["widget_name"] = widget_name
if graph_identifier is not None:
payload["graph_id"] = str(graph_identifier)
try:
self._prompt_server.instance.send_sync("lm_widget_update", payload)
results.append(
{
"node_id": parsed_node_id,
"graph_id": payload.get("graph_id"),
"success": True,
}
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error sending widget update to node %s (graph %s): %s",
parsed_node_id,
graph_identifier,
exc,
)
results.append(
{
"node_id": parsed_node_id,
"graph_id": payload.get("graph_id"),
"success": False,
"error": str(exc),
}
)
return web.json_response({"success": True, "results": results})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to update node widget (GET): %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class MiscHandlerSet:
"""Aggregate handlers into a lookup compatible with the registrar."""
@@ -3354,13 +3738,17 @@ 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,
"get_update_lora_code": self.lora_code.get_update_lora_code,
"get_trained_words": self.trained_words.get_trained_words,
"get_model_example_files": self.model_examples.get_model_example_files,
"register_nodes": self.node_registry.register_nodes,
"update_node_widget": self.node_registry.update_node_widget,
"get_update_node_widget": self.node_registry.get_update_node_widget,
"get_registry": self.node_registry.get_registry,
"check_model_exists": self.model_library.check_model_exists,
"check_models_exist": self.model_library.check_models_exist,
@@ -3387,6 +3775,7 @@ 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,
"set_hf_url": self.hf_handler.set_hf_url,
# Agent skill handlers
"get_agent_skills": self.agent_handler.get_agent_skills,
"execute_agent_skill": self.agent_handler.execute_agent_skill,
+70 -14
View File
@@ -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:
@@ -963,6 +973,8 @@ class ModelQueryHandler:
limit = int(request.query.get("limit", "20"))
if limit < 0:
limit = 20
elif limit > 200:
limit = 20
top_tags = await self._service.get_top_tags(limit)
return web.json_response({"success": True, "tags": top_tags})
except Exception as exc:
@@ -971,6 +983,22 @@ class ModelQueryHandler:
{"success": False, "error": "Internal server error"}, status=500
)
async def search_tags(self, request: web.Request) -> web.Response:
try:
query = request.query.get("q", "")
limit = int(request.query.get("limit", "20"))
if limit < 0:
limit = 20
elif limit > 200:
limit = 20
tags = await self._service.search_tags(query, limit)
return web.json_response({"success": True, "tags": tags})
except Exception as exc:
self._logger.error("Error searching tags: %s", exc, exc_info=True)
return web.json_response(
{"success": False, "error": "Internal server error"}, status=500
)
async def get_base_models(self, request: web.Request) -> web.Response:
try:
limit = int(request.query.get("limit", "20"))
@@ -1265,9 +1293,13 @@ class ModelQueryHandler:
text=f"{self._service.model_type.capitalize()} file name is required",
status=400,
)
notes = await self._service.get_model_notes(model_name)
if notes is not None:
return web.json_response({"success": True, "notes": notes})
result = await self._service.get_model_notes(model_name)
if result is not None:
return web.json_response({
"success": True,
"notes": result["notes"],
"file_path": result["file_path"],
})
return web.json_response(
{
"success": False,
@@ -1303,9 +1335,20 @@ class ModelQueryHandler:
}
if include_license_flags:
model_data = await self._service.get_model_info_by_name(model_name)
license_flags = (model_data or {}).get("license_flags")
if license_flags is not None:
response_payload["license_flags"] = int(license_flags)
# Only return license_flags when real CivitAI model license
# data exists. This mirrors ModelModal's guard
# (modelData?.civitai?.model) so the preview tooltip never
# shows misleading license icons for HF or other models
# without actual license metadata.
civitai_data = (model_data or {}).get("civitai") or {}
has_license_data = (
isinstance(civitai_data, dict)
and isinstance(civitai_data.get("model"), dict)
)
if has_license_data:
license_flags = (model_data or {}).get("license_flags")
if license_flags is not None:
response_payload["license_flags"] = int(license_flags)
# Include the user's license icon style preference so the
# ComfyUI tooltip can pick the right set without a separate
# API call.
@@ -1762,14 +1805,20 @@ class ModelDownloadHandler:
async def delete_download_history_item(self, request: web.Request) -> web.Response:
try:
item_id = int(request.query.get("id", "0"))
if not item_id:
download_id = request.query.get("download_id")
id_str = request.query.get("id")
item_id = int(id_str) if id_str else None
if not download_id and not item_id:
return web.json_response(
{"success": False, "error": "id is required"}, status=400
{"success": False, "error": "id or download_id is required"},
status=400,
)
service = await DownloadQueueService.get_instance()
deleted = await service.delete_history_item(item_id)
deleted = await service.delete_history_item(
id=item_id, download_id=download_id
)
return web.json_response({"success": deleted})
except Exception as exc:
self._logger.error(
@@ -1779,14 +1828,20 @@ class ModelDownloadHandler:
async def retry_download_from_history(self, request: web.Request) -> web.Response:
try:
item_id = int(request.query.get("id", "0"))
if not item_id:
download_id = request.query.get("download_id")
id_str = request.query.get("id")
item_id = int(id_str) if id_str else None
if not download_id and not item_id:
return web.json_response(
{"success": False, "error": "id is required"}, status=400
{"success": False, "error": "id or download_id is required"},
status=400,
)
service = await DownloadQueueService.get_instance()
item = await service.retry_from_history(item_id)
item = await service.retry_from_history(
item_id=item_id, download_id=download_id
)
if item is None:
return web.json_response(
{"success": False, "error": "History item not found or not retryable"},
@@ -2910,6 +2965,7 @@ class ModelHandlerSet:
"bulk_delete_models": self.management.bulk_delete_models,
"verify_duplicates": self.management.verify_duplicates,
"get_top_tags": self.query.get_top_tags,
"search_tags": self.query.search_tags,
"get_base_models": self.query.get_base_models,
"get_model_types": self.query.get_model_types,
"scan_models": self.query.scan_models,
+31
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import logging
import mimetypes
import urllib.parse
@@ -53,6 +54,7 @@ class PreviewHandler:
if not resolved.is_file():
logger.debug("Preview file not found at %s", str(resolved))
asyncio.create_task(self._cleanup_stale_preview_url(normalized))
raise web.HTTPNotFound(text="Preview file not found")
# aiohttp's FileResponse handles range requests, content headers, and
@@ -69,6 +71,35 @@ class PreviewHandler:
resp.headers["Cache-Control"] = "public, max-age=86400"
return resp
async def _cleanup_stale_preview_url(self, normalized_preview_path: str) -> None:
"""Fire-and-forget: clear stale preview_url from all model caches.
When a preview file is no longer on disk, remove its reference from
every cached entry so subsequent list API responses return an empty
``preview_url``, letting the frontend show the no-preview placeholder.
"""
try:
from ...services.service_registry import ServiceRegistry
for service_name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
scanner = ServiceRegistry.get_service_sync(service_name)
if scanner is None or not hasattr(scanner, "_cache"):
continue
cache = getattr(scanner, "_cache", None)
if cache is None or not hasattr(cache, "clear_preview_by_path"):
continue
cleared = await cache.clear_preview_by_path(normalized_preview_path)
if cleared and hasattr(scanner, "_persist_current_cache"):
await scanner._persist_current_cache()
logger.info(
"Cleared stale preview_url for %d %s entries (%s)",
cleared,
service_name,
normalized_preview_path,
)
except Exception as exc:
logger.debug("Failed to clean up stale preview_url: %s", exc)
async def _stream_file(
self, request: web.Request, path: Path
) -> web.StreamResponse:
+80 -6
View File
@@ -72,6 +72,7 @@ class RecipeHandlerSet:
"save_recipe": self.management.save_recipe,
"delete_recipe": self.management.delete_recipe,
"get_top_tags": self.query.get_top_tags,
"search_tags": self.query.search_tags,
"get_base_models": self.query.get_base_models,
"get_roots": self.query.get_roots,
"get_folders": self.query.get_folders,
@@ -317,12 +318,11 @@ class RecipeQueryHandler:
raise RuntimeError("Recipe scanner unavailable")
limit = int(request.query.get("limit", "20"))
cache = await recipe_scanner.get_cached_data()
tag_counts: Dict[str, int] = {}
for recipe in getattr(cache, "raw_data", []):
for tag in recipe.get("tags", []) or []:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
if limit < 0:
limit = 20
elif limit > 200:
limit = 20
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
sorted_tags = [
{"tag": tag, "count": count} for tag, count in tag_counts.items()
@@ -333,6 +333,55 @@ class RecipeQueryHandler:
self._logger.error("Error retrieving top tags: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def search_tags(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
query = request.query.get("q", "")
limit = int(request.query.get("limit", "20"))
if limit < 0:
limit = 20
elif limit > 200:
limit = 20
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
normalized_query = (query or "").strip().lower()
if not normalized_query:
sorted_tags = [
{"tag": tag, "count": count} for tag, count in tag_counts.items()
]
sorted_tags.sort(key=lambda entry: entry["count"], reverse=True)
return web.json_response(
{"success": True, "tags": sorted_tags[: (limit if limit > 0 else 20)]}
)
matched = [
{"tag": tag, "count": count}
for tag, count in tag_counts.items()
if normalized_query in tag.lower()
]
matched.sort(key=lambda entry: entry["count"], reverse=True)
if limit == 0:
result = matched
else:
result = matched[:limit]
return web.json_response({"success": True, "tags": result})
except Exception as exc:
self._logger.error("Error searching recipe tags: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def _get_recipe_tag_counts(self, recipe_scanner) -> Dict[str, int]:
"""Compute tag->count mapping from cached recipe data."""
cache = await recipe_scanner.get_cached_data()
tag_counts: Dict[str, int] = {}
for recipe in getattr(cache, "raw_data", []):
for tag in recipe.get("tags", []) or []:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
return tag_counts
async def get_base_models(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
@@ -2218,6 +2267,31 @@ class RecipeManagementHandler:
"Failed to download image for recipe: %s", exc
)
# Fallback: try to locate a custom image on disk using model_hash + image id
if image_bytes is None:
image_id = image_data.get("id") or ""
if image_id and model_hash:
from ...utils.example_images_paths import get_model_folder
model_folder = get_model_folder(model_hash)
if model_folder and os.path.exists(model_folder):
for fname in os.listdir(model_folder):
if f"custom_{image_id}" in fname:
ext = os.path.splitext(fname)[1].lower()
if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
continue
fpath = os.path.join(model_folder, fname)
if os.path.isfile(fpath):
try:
with open(fpath, "rb") as f:
image_bytes = f.read()
extension = ext
except Exception as exc:
self._logger.warning(
"Failed to read custom image file %s: %s",
fpath, exc,
)
break
prompt = (
(parsed.get("gen_params") or {}).get("prompt") or ""
)
+7
View File
@@ -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"),
@@ -37,10 +39,12 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
RouteDefinition("GET", "/api/lm/update-lora-code", "get_update_lora_code"),
RouteDefinition("GET", "/api/lm/trained-words", "get_trained_words"),
RouteDefinition("GET", "/api/lm/model-example-files", "get_model_example_files"),
RouteDefinition("POST", "/api/lm/register-nodes", "register_nodes"),
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
RouteDefinition("GET", "/api/lm/update-node-widget", "get_update_node_widget"),
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
@@ -101,6 +105,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"POST", "/api/lm/download-hf-model", "download_hf_model"
),
RouteDefinition(
"POST", "/api/lm/set-hf-url", "set_hf_url"
),
# Agent skill endpoints
RouteDefinition(
"GET", "/api/lm/agent/skills", "get_agent_skills"
+1
View File
@@ -46,6 +46,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
),
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
+1
View File
@@ -29,6 +29,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/recipes/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
+6 -2
View File
@@ -1,8 +1,12 @@
"""Agent-powered skill system for LoRA Manager.
"""LLM-powered metadata enrichment pipeline infrastructure.
This package provides the orchestration layer for LLM/agent-powered features.
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
+133 -57
View File
@@ -1,16 +1,17 @@
"""Agent orchestration service.
"""Pipeline orchestration service.
The :class:`AgentService` coordinates skill execution:
The :class:`AgentService` coordinates LLM-powered pipeline execution:
1. Look up the skill in :class:`SkillRegistry`
2. Validate input against the skill's ``input_schema``
3. Prepare context via :mod:`~py.agent_cli` (read metadata, list base models, fetch HF README)
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.agent_cli`)
5. Post-process via :class:`PostProcessor` (delegates I/O to :mod:`~py.metadata_ops`)
6. Broadcast progress and completion via :class:`WebSocketManager`
Skills define *what* to do (prompt template). The AgentService handles *how*
(LLM calls, context gathering, validation, progress).
Pipeline definitions (*skills*) describe *what* to do (prompt template).
The AgentService handles *how* (LLM calls, context gathering, validation,
progress).
"""
from __future__ import annotations
@@ -24,10 +25,17 @@ 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__)
@@ -195,11 +203,11 @@ class AgentService:
input_data: Dict[str, Any],
progress_callback: Optional[AgentProgressReporter] = None,
) -> SkillResult:
"""Execute an agent skill.
"""Execute a pipeline (skill) on the given models.
Args:
skill_name: Name of the skill to execute
input_data: Input validated against the skill's ``input_schema``
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:
@@ -207,7 +215,6 @@ class AgentService:
"""
registry = await self._ensure_registry()
logger.info("execute_skill '%s': looking up skill", skill_name)
skill = registry.get_skill(skill_name)
if skill is None:
return SkillResult(
@@ -235,11 +242,11 @@ class AgentService:
total = len(model_paths)
processed = 0
success_count = 0
skipped_count = 0
updated_models: List[Dict[str, Any]] = []
errors: List[str] = []
post_processor = PostProcessor()
logger.info("execute_skill '%s': starting with %d model(s)", skill_name, total)
await self._emit_progress(
progress_callback, skill_name, status="started",
total=total, processed=0, success=0,
@@ -249,52 +256,74 @@ class AgentService:
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(
"execute_skill '%s': processing model %d/%d: %s",
skill_name, processed + 1, total, model_path,
"[%s] [%d/%d] %s",
skill_name, processed + 1, total, model_filename,
)
updated_data: Dict[str, Any] = {}
skip_model = False
try:
from ...agent_cli import read_metadata
from ...metadata_ops import read_metadata
metadata = await read_metadata(model_path)
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)
# 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(
"execute_skill '%s': LLM call for %s (prompt=%d chars)",
skill_name, model_path, len(rendered),
"[%s] SKIP %s — no hf_url in metadata",
skill_name, model_filename,
)
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,
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", ""),
)
model_result = await post_processor.process(
skill_name=skill_name,
model_path=model_path,
llm_output=llm_response or {},
metadata=metadata,
)
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})
else:
errors.extend(
model_result.get("errors", [model_result.get("error", "Unknown error")])
)
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)
@@ -304,25 +333,41 @@ class AgentService:
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",
summary=f"Processed {processed}/{total} models, {success_count} succeeded, {skipped_count} skipped",
)
logger.info("execute_skill '%s': done — %s", skill_name, result.summary)
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,
@@ -334,20 +379,29 @@ class AgentService:
"""Gather variables for the skill's prompt template.
Reads metadata, fetches the HF README (if applicable), lists available
base models, and returns a dict that maps to ``{{variable}}``
placeholders in ``prompt.md``.
base models, loads user priority tags, and returns a dict that maps to
``{{variable}}`` placeholders in ``prompt.md``.
"""
from ...agent_cli import list_base_models
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", ""),
@@ -364,12 +418,34 @@ class AgentService:
context["repo"] = repo or ""
if repo:
readme = await self._fetch_readme(repo)
context["readme_content"] = readme[:8000] if readme else "(README not available)"
# 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:
context["base_models"] = await list_base_models()
raw_models = await list_base_models()
context["base_models"] = self._format_base_models(raw_models)
except Exception as exc:
logger.debug("Failed to list base models: %s", exc)
context["base_models"] = "</not available>"
# Determine model type and load the corresponding priority_tags
try:
model_type = await identify_model_type(model_path)
context["model_type"] = model_type
settings = SettingsManager()
priority_config = settings.get_priority_tag_config()
context["priority_tags"] = priority_config.get(model_type, "")
except Exception as exc:
logger.debug("Failed to load priority tags: %s", exc)
context["model_type"] = "lora"
context["priority_tags"] = ""
return context
+194 -26
View File
@@ -1,17 +1,19 @@
"""Post-processing engine for agent skill outputs.
"""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.agent_cli` functions.
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.agent_cli`.
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
@@ -19,7 +21,7 @@ logger = logging.getLogger(__name__)
class PostProcessor:
"""Deterministic post-processor for agent skill outputs.
"""Deterministic post-processor for skill pipeline outputs.
Usage (called by :class:`~py.services.agent.agent_service.AgentService`)::
@@ -28,7 +30,7 @@ class PostProcessor:
skill_name="enrich_hf_metadata",
model_path="/path/to/model.safetensors",
llm_output={...},
metadata={...}, # from agent_cli.read_metadata()
metadata={...}, # from metadata_ops.read_metadata()
)
"""
@@ -39,15 +41,20 @@ class PostProcessor:
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,
model_path, llm_output, metadata, readme_content,
)
return {
"success": False,
@@ -64,12 +71,22 @@ class PostProcessor:
model_path: str,
llm_output: Dict[str, Any],
metadata: Dict[str, Any],
readme_content: str = "",
) -> Dict[str, Any]:
from ...agent_cli import (
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
@@ -86,23 +103,89 @@ class PostProcessor:
if new_base and self._should_overwrite(current_base, is_hf_model):
updates["base_model"] = new_base
# trainedWords / trigger words
# 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()]
if cleaned:
current_triggers = metadata.get("trainedWords") or []
if self._should_overwrite_list(current_triggers, is_hf_model):
updates["trainedWords"] = cleaned
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
new_desc = (llm_output.get("description") or "").strip()
if new_desc:
current_desc = metadata.get("modelDescription", "") or ""
if self._should_overwrite(current_desc, is_hf_model):
updates["modelDescription"] = new_desc
# 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
# tags — merge with existing, deduplicate (case-insensitive)
# 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 `<img>` tags (used by many collection repos)
html_images = extract_html_img_tags(
readme_content, repo,
existing_urls=existing_urls,
default_width=rec_w, default_height=rec_h,
)
all_images = gallery + table_images + simple_images + html_images
if all_images:
gallery_images = all_images
current_civitai = metadata.get("civitai") or {}
gallery_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
gallery_civitai.update(updates["civitai"])
gallery_civitai["images"] = all_images
updates["civitai"] = gallery_civitai
# tags
new_tags = llm_output.get("tags", [])
if isinstance(new_tags, list) and new_tags:
existing_tags = metadata.get("tags") or []
@@ -114,16 +197,68 @@ class PostProcessor:
updates["metadata_source"] = "agent:enrich_hf_metadata"
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
# -- Persist updates ------------------------------------------------
# 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)
# -- Download preview -----------------------------------------------
preview_url = (llm_output.get("preview_url") or "").strip()
current_preview = metadata.get("preview_url") or ""
if preview_url and not (current_preview and os.path.exists(current_preview)):
preview_downloaded = await download_preview(model_path, preview_url)
# -- Refresh scanner cache ------------------------------------------
if updated_fields or preview_downloaded:
await refresh_cache(model_path)
@@ -132,6 +267,7 @@ class PostProcessor:
"success": True,
"updated_fields": updated_fields,
"preview_downloaded": preview_downloaded,
"updates": updates,
"errors": [],
}
@@ -166,3 +302,35 @@ class PostProcessor:
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 ""
+41 -15
View File
@@ -1,7 +1,7 @@
"""Discovery and loading of agent skills.
"""Discovery and loading of prompt-based skills.
Skills live in ``py/services/agent/skills/<name>/`` directories. Each
directory must contain a ``SKILL.md`` file with YAML frontmatter::
directory must contain a ``prompt.md`` file with YAML frontmatter::
---
name: my_skill
@@ -12,6 +12,8 @@ directory must contain a ``SKILL.md`` file with YAML frontmatter::
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.
"""
@@ -32,6 +34,11 @@ 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
@@ -43,7 +50,8 @@ _FRONTMATTER_RE = re.compile(
def _parse_skill_file(path: Path) -> tuple[dict, str]:
"""Read a ``SKILL.md`` file and return (frontmatter_dict, body_text).
"""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.
"""
@@ -95,6 +103,20 @@ class SkillRegistry:
# 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."""
@@ -107,31 +129,32 @@ class SkillRegistry:
for entry in sorted(self._skills_dir.iterdir()):
if not entry.is_dir():
continue
skill_md = entry / "SKILL.md"
if not skill_md.exists():
prompt_file = self._find_prompt_file(entry)
if prompt_file is None:
continue
try:
definition = self._load_skill_definition(skill_md)
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", skill_md, exc)
logger.warning("Failed to load skill from %s: %s", prompt_file, exc)
self._loaded = True
logger.info("Discovered %d agent skills", len(self._skills))
logger.info("Discovered %d prompt-based skills", len(self._skills))
def _load_skill_definition(self, path: Path) -> Optional[SkillDefinition]:
"""Parse a ``SKILL.md`` frontmatter into a :class:`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 SKILL.md %s: %s", path, exc)
logger.warning("Failed to parse prompt file %s: %s", path, exc)
return None
if "name" not in data:
logger.warning("SKILL.md missing required 'name' field: %s", path)
logger.warning("Prompt file %s missing required 'name' field", path)
return None
perm_data = data.get("permissions", {})
@@ -171,12 +194,15 @@ class SkillRegistry:
return self._skills.get(name)
def load_prompt(self, name: str) -> str:
"""Load and return the prompt template body from a skill's ``SKILL.md``."""
"""Load and return the prompt template body for the named skill."""
skill_dir = self._skills_dir / name
skill_path = skill_dir / "SKILL.md"
if not skill_path.exists():
raise FileNotFoundError(f"SKILL.md not found: {skill_path}")
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
@@ -1,89 +0,0 @@
---
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}}
- **Repository ID**: {{repo}}
## Current Metadata (may be incomplete)
```json
{{current_metadata}}
```
## Available Base Models
The following base models are currently valid in this system:
{{base_models}}
## HuggingFace README Content
```
{{readme_content}}
```
## Extraction Instructions
Extract the following information from the README content above:
### base_model
The base model this LoRA/checkpoint 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 (between --- markers) for `base_model:` first, then look at the description text and safetensors metadata. If you cannot determine it, return an empty string.
### 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:"
- 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.
### description
A concise 1-2 sentence summary of what this model does. Extract from the "Model description" section or the first paragraph. Return empty string if the README is too minimal.
### tags
3-8 relevant tags for categorizing this model. Extract from:
- The YAML frontmatter `tags:` list (often contains excellent categorization tags)
- The model type (e.g. "lora", "checkpoint", "flux", "sdxl")
- The style/subject (e.g. "anime", "photorealistic", "style", "character")
All lowercase, no spaces. Return empty array if none found.
### preview_url
The URL of the most suitable preview image from the README. Look for image tags (e.g. `![alt](url)`) and the YAML frontmatter `widget:` section (which often has `output.url` fields). 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.
### 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
## Output Format
Return ONLY a JSON object with exactly these fields (no markdown fences, no extra text):
```json
{
"model_path": "{{model_path}}",
"base_model": "<canonical name or empty string>",
"trigger_words": ["<word1>", "<word2>"],
"description": "<1-2 sentence summary>",
"tags": ["<tag1>", "<tag2>"],
"preview_url": "<image URL or empty string>",
"confidence": "<high|medium|low>"
}
```
Important:
- Only include the JSON object, no other text
- If a field cannot be determined, use an empty string or empty array
- Do not fabricate information not supported by the README
@@ -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** (`<a id="...">`) or section headings whose text matches words from the filename.
3. **Search for HTML headings** (`<h1>`, `<h2>`, `<span>`) containing parts of the filename.
4. If no match is found, use the full README as usual — the model may be the only one in the repo.
When a matching section IS found, prefer metadata from that section.
When no section matches (e.g. single-model repos or repos without per-file sections),
extract metadata from the full README normally. Do not return empty data just
because the filename doesn't appear in the README.
## Output Format
Return ONLY a JSON object with exactly these fields (no markdown fences, no extra text):
```json
{
"model_path": "{{model_path}}",
"base_model": "<canonical name or empty string>",
"trigger_words": ["<word1>", "<word2>"],
"short_description": "<1-2 sentence summary>",
"tags": ["<tag1>", "<tag2>"],
"recommended_width": 768,
"recommended_height": 1024,
"preview_url": "<image URL or empty string>",
"notes": "<plain-text usage summary or empty string>",
"usage_tips": "<JSON string like '{\"strength_min\":0.85,\"strength_max\":1.4}' or '{}'>",
"confidence": "<high|medium|low>"
}
```
Important:
- Only include the JSON object, no other text
- If a field cannot be determined, use an empty string or empty array
- Do not fabricate information not supported by the README
- Never use placeholder values like `"None"` or `"unknown"` for missing data — use empty string or empty array
File diff suppressed because it is too large Load Diff
+7
View File
@@ -201,6 +201,13 @@ class Aria2Downloader:
"auto-file-renaming": "false",
"file-allocation": "none",
}
# Pass proxy to aria2 so the actual file transfer goes through the
# same proxy used by the aiohttp-based URL resolution step above.
downloader = await get_downloader()
if downloader.proxy_url:
options["all-proxy"] = downloader.proxy_url
if request_headers:
options["header"] = [
f"{key}: {value}" for key, value in request_headers.items()
+36 -4
View File
@@ -804,6 +804,12 @@ class BaseModelService(ABC):
"""Get top tags sorted by frequency"""
return await self.scanner.get_top_tags(limit)
async def search_tags(
self, query: str, limit: int = 50
) -> List[Dict]:
"""Search tags by substring, sorted by frequency"""
return await self.scanner.search_tags(query, limit)
async def get_base_models(self, limit: int = 20) -> List[Dict]:
"""Get base models sorted by frequency"""
return await self.scanner.get_base_models(limit)
@@ -955,13 +961,21 @@ class BaseModelService(ABC):
return unified_tree
async def get_model_notes(self, model_name: str) -> Optional[str]:
"""Get notes for a specific model file"""
async def get_model_notes(self, model_name: str) -> Optional[dict]:
"""Get notes and file_path for a specific model file.
Supports both simple names (``OWSMianne_ANIMA_V1``) and full-path
syntax (``Anima/character/OWSMianne_ANIMA_V1``).
"""
cache = await self.scanner.get_cached_data()
for model in cache.raw_data:
if model["file_name"] == model_name:
return model.get("notes", "")
file_name = model.get("file_name", "")
if file_name == model_name or model_name.endswith("/" + file_name) or model_name.endswith("\\" + file_name):
return {
"notes": model.get("notes", ""),
"file_path": model.get("file_path", ""),
}
return None
@@ -1084,6 +1098,11 @@ class BaseModelService(ABC):
Listing/search endpoints return lightweight cache entries; this method performs
a lazy read of the on-disk metadata snapshot when callers need full detail.
As a beneficial side effect, the in-memory and persistent caches are
opportunistically synchronised with the on-disk metadata this keeps the
caches fresh even when a ``.metadata.json`` file was edited outside of the
normal save path (e.g. manually or by an external script).
"""
metadata, should_skip = await MetadataManager.load_metadata(
file_path, self.metadata_class
@@ -1101,6 +1120,19 @@ class BaseModelService(ABC):
MetadataManager.save_metadata(file_path, metadata)
)
# Opportunistically sync the in-memory + persistent caches.
# The .metadata.json disk read is already paid for; the sync only
# performs work when the cache is actually stale, and uses targeted,
# in-place operations to minimise overhead even with large model sets.
#
# Fire-and-forget by design: the task is intentionally untracked.
# sync_cache_from_metadata handles its own errors internally.
asyncio.create_task(
self.scanner.sync_cache_from_metadata(
file_path, metadata.to_dict()
)
)
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
async def get_model_description(self, file_path: str) -> Optional[str]:
+25
View File
@@ -114,6 +114,13 @@ class CheckpointScanner(ModelScanner):
and metadata.hash_status == "completed"
and metadata.sha256
):
# Ensure the in-memory hash index is populated even when
# the hash was already computed and persisted to the metadata
# file. Without this, usage tracking (and any other caller
# that queries get_hash_by_filename first) will miss on every
# lookup and keep calling back into this method, creating a
# tight loop that never populates the index.
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
return metadata.sha256
async with self._hash_calculation_lock:
@@ -125,6 +132,7 @@ class CheckpointScanner(ModelScanner):
and metadata.hash_status == "completed"
and metadata.sha256
):
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
return metadata.sha256
task = self._hash_calculation_tasks.get(real_path)
@@ -175,6 +183,9 @@ class CheckpointScanner(ModelScanner):
# Check if hash is already calculated
if metadata.hash_status == "completed" and metadata.sha256:
# Populate the in-memory hash index even for pre-computed
# hashes, mirroring the fix in calculate_hash_for_model.
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
return metadata.sha256
# Update status to calculating
@@ -193,6 +204,20 @@ class CheckpointScanner(ModelScanner):
# Update hash index
self._hash_index.add_entry(sha256.lower(), file_path)
# Update the in-memory cache entry so that subsequent
# _persist_current_cache / _save_persistent_cache calls
# write the hash back to the SQLite models table. Without
# this the hash only lives in the metadata file and the
# in-memory hash index, both of which are lost across
# restarts, causing the same re-computation loop on the
# next session.
if self._cache is not None and self._cache.raw_data:
for entry in self._cache.raw_data:
if entry.get("file_path") == file_path:
entry["sha256"] = sha256.lower()
entry["hash_status"] = "completed"
break
logger.info(f"Hash calculated for checkpoint: {file_path}")
return sha256
+14
View File
@@ -304,6 +304,20 @@ class CivArchiveClient:
version_id = file_data.get("model_version_id") or file_data.get("modelVersionId")
if model_id is None or version_id is None:
continue
# CivitAI / CivArchive model IDs are small integers (typically ≤ 7
# digits). Reject suspiciously large values that indicate the API
# returned a malformed payload (e.g. a hash reinterpreted as an ID)
# to avoid pointless HTTP 500 errors from CivArchive.
_MAX_VALID_CIVITAI_ID = 100_000_000
try:
if int(model_id) >= _MAX_VALID_CIVITAI_ID or int(version_id) >= _MAX_VALID_CIVITAI_ID:
logger.debug(
"Skipping implausible CivArchive model_id=%s / version_id=%s",
model_id, version_id,
)
continue
except (TypeError, ValueError):
continue
resolved = await self.get_model_version(model_id, version_id)
if resolved:
return resolved
+24
View File
@@ -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",
],
}
+67 -43
View File
@@ -230,6 +230,12 @@ class DownloadManager:
Returns:
Dict with download result
"""
logger.debug(
"[download] download_from_civitai called: model_id=%s, model_version_id=%s, "
"source=%s, file_params=%s",
model_id, model_version_id, source, file_params,
)
# Validate that at least one identifier is provided
if not model_id and not model_version_id:
return {
@@ -250,6 +256,7 @@ class DownloadManager:
"source": source,
"file_params": copy.deepcopy(file_params) if file_params is not None else None,
"progress": 0,
"status": "queued",
"transfer_backend": self._get_model_download_backend(),
"bytes_downloaded": 0,
@@ -289,8 +296,8 @@ class DownloadManager:
return result
except asyncio.CancelledError:
return {
"success": False,
"error": "Download was cancelled",
"success": True,
"cancelled": True,
"download_id": task_id,
}
finally:
@@ -675,7 +682,10 @@ class DownloadManager:
u for u in download_urls if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
]
download_urls = non_civitai_urls + civitai_urls
else:
# Fallback: when mirrors is empty or all mirrors have been deleted,
# use the file's downloadUrl directly (e.g. CivitAI download endpoint).
if not download_urls:
download_url = file_info.get("downloadUrl")
if download_url:
download_urls.append(normalize_civitai_download_url(download_url))
@@ -1421,14 +1431,35 @@ class DownloadManager:
# If file_params is provided, try to find matching file
if file_params and model_version_id:
target_file_id = file_params.get("id")
target_type = file_params.get("type", "Model")
target_format = file_params.get("format", "SafeTensor")
target_size = file_params.get("size", "full")
target_format = file_params.get("format")
target_size = file_params.get("size")
target_fp = file_params.get("fp")
is_primary = file_params.get("isPrimary", False)
if is_primary:
# Find primary file
logger.debug(
"[download] file_params received: id=%s, type=%s, format=%s, size=%s, fp=%s, isPrimary=%s, "
"model_version_id=%s, total_files=%d",
target_file_id, target_type, target_format, target_size, target_fp, is_primary,
model_version_id, len(files),
)
if target_file_id:
target_id_str = str(target_file_id)
for f in files:
f_id = f.get("id")
if str(f_id) == target_id_str:
file_info = f
logger.debug(
"[download] MATCH by ID: id=%s name='%s'",
f_id, f.get("name"),
)
break
if not file_info:
logger.debug("[download] No file found with id=%s", target_file_id)
elif is_primary:
file_info = next(
(
f
@@ -1439,28 +1470,41 @@ class DownloadManager:
None,
)
else:
# Match by metadata
# Lenient metadata match: only compare fields present on both sides
for f in files:
f_type = f.get("type", "")
f_meta = f.get("metadata", {})
# Check type match
if f_type != target_type:
continue
# Check metadata match
if f_meta.get("format") != target_format:
f_meta = f.get("metadata", {})
f_format = f_meta.get("format") or f.get("format")
f_size = f_meta.get("size") or f.get("size")
f_fp = f_meta.get("fp") or f.get("fp")
if target_format and f_format != target_format:
continue
if f_meta.get("size") != target_size:
if target_size and f_size and f_size != target_size:
continue
if target_fp and f_meta.get("fp") != target_fp:
if target_fp and f_fp and f_fp != target_fp:
continue
file_info = f
break
if not file_info:
logger.debug(
"[download] No match found via file_params — falling back to primary file lookup",
)
elif not file_params:
logger.debug(
"[download] No file_params provided (null/None) — will use primary file lookup. "
"model_version_id=%s, total_files=%d",
model_version_id, len(files),
)
# Fallback to primary file if no match found
if not file_info:
logger.debug("[download] Looking for primary file as fallback")
file_info = next(
(
f
@@ -1469,38 +1513,18 @@ class DownloadManager:
),
None,
)
if file_info:
logger.debug(
"[download] Fallback primary file selected: id=%s, name=%s",
file_info.get("id"), file_info.get("name"),
)
else:
logger.debug("[download] No primary file found in fallback lookup")
if not file_info:
return {"success": False, "error": "No suitable file found in metadata"}
mirrors = file_info.get("mirrors") or []
download_urls = []
if mirrors:
for mirror in mirrors:
if mirror.get("deletedAt") is None and mirror.get("url"):
download_urls.append(
normalize_civitai_download_url(mirror["url"])
)
# When source is 'civarchive', prioritize non-Civitai URLs
# This avoids failed downloads from deleted Civitai models
if source == "civarchive" and len(download_urls) > 1:
civitai_urls = [
u
for u in download_urls
if u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
]
non_civitai_urls = [
u
for u in download_urls
if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
]
download_urls = non_civitai_urls + civitai_urls
else:
download_url = file_info.get("downloadUrl")
if download_url:
download_urls.append(
normalize_civitai_download_url(download_url)
)
download_urls = self._build_download_urls_from_file_info(file_info, source=source)
if not download_urls:
return {"success": False, "error": "No mirror URL found"}
+54 -19
View File
@@ -74,6 +74,8 @@ class DownloadQueueService:
);
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_dh_download_id
ON download_history(download_id) WHERE download_id IS NOT NULL;
"""
@classmethod
@@ -154,13 +156,23 @@ class DownloadQueueService:
"""Insert a new download into the queue.
Returns the inserted row as a dict (or an empty dict if the
download_id already exists).
download_id already exists in the queue or has a terminal
record in history).
"""
now = time.time()
file_params_json = json.dumps(file_params) if file_params is not None else None
async with self._lock:
conn = self._get_conn()
# Reject download_ids that already have a terminal record in history.
history_row = conn.execute(
"SELECT 1 FROM download_history WHERE download_id = ? LIMIT 1",
(download_id,),
).fetchone()
if history_row is not None:
return {}
conn.execute(
"""
INSERT OR IGNORE INTO download_queue (
@@ -380,7 +392,7 @@ class DownloadQueueService:
)
conn.execute(
"""
INSERT INTO download_history (
INSERT OR IGNORE INTO download_history (
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, status, error, file_path,
bytes_downloaded, total_bytes, completed_at
@@ -537,17 +549,27 @@ class DownloadQueueService:
"offset": offset,
}
async def delete_history_item(self, id: int) -> bool:
"""Delete a single history entry by its *id*.
async def delete_history_item(
self, id: Optional[int] = None, download_id: Optional[str] = None
) -> bool:
"""Delete a single history entry by *download_id* (preferred) or *id*.
Returns ``True`` if a row was deleted.
"""
async with self._lock:
conn = self._get_conn()
cursor = conn.execute(
"DELETE FROM download_history WHERE id = ?",
(id,),
)
if download_id:
cursor = conn.execute(
"DELETE FROM download_history WHERE download_id = ?",
(download_id,),
)
elif id is not None:
cursor = conn.execute(
"DELETE FROM download_history WHERE id = ?",
(id,),
)
else:
return False
conn.commit()
return cursor.rowcount > 0
@@ -604,21 +626,34 @@ class DownloadQueueService:
# Retry
# ------------------------------------------------------------------
async def retry_from_history(self, item_id: int) -> Optional[dict[str, Any]]:
async def retry_from_history(
self,
item_id: Optional[int] = None,
download_id: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""Re-queue a failed or canceled download from history.
Looks up the history record by its primary key. If the status is
``failed`` or ``canceled`` a new queue entry is created with the
same model metadata and a fresh download id, and the original
history entry is **deleted** to prevent exponential growth when
the retried item is later canceled or fails again and re-retried.
Looks up the history record by *download_id* (preferred) or
*item_id*. If the status is ``failed`` or ``canceled`` a new
queue entry is created with the same model metadata and a fresh
download id, and the original history entry is **deleted** to
prevent exponential growth when the retried item is later
canceled or fails again and re-retried.
"""
async with self._lock:
conn = self._get_conn()
row = conn.execute(
"SELECT * FROM download_history WHERE id = ?",
(item_id,),
).fetchone()
if download_id:
row = conn.execute(
"SELECT * FROM download_history WHERE download_id = ?",
(download_id,),
).fetchone()
elif item_id is not None:
row = conn.execute(
"SELECT * FROM download_history WHERE id = ?",
(item_id,),
).fetchone()
else:
return None
if row is None:
return None
status = str(row["status"])
@@ -650,7 +685,7 @@ class DownloadQueueService:
)
conn.execute(
"DELETE FROM download_history WHERE id = ?",
(item_id,),
(row["id"],),
)
conn.commit()
queued = conn.execute(
+56 -10
View File
@@ -46,6 +46,30 @@ def is_ssl_cert_verify_error(exc: BaseException) -> bool:
return "CERTIFICATE_VERIFY_FAILED" in str(exc)
def _parse_retry_after(value: str) -> int:
"""Parse a Retry-After header value into seconds.
Supports both integer seconds and HTTP-date formats.
Returns a default of 60 seconds on invalid/missing input.
"""
if not value or not value.strip():
return 60
value = value.strip()
try:
return max(1, int(value))
except ValueError:
pass
try:
parsed = parsedate_to_datetime(value)
now = datetime.now().astimezone()
delta = (parsed - now).total_seconds()
return max(1, int(delta))
except (ValueError, OverflowError, OSError):
return 60
@dataclass(frozen=True)
class DownloadProgress:
"""Snapshot of a download transfer at a moment in time."""
@@ -246,14 +270,14 @@ class Downloader:
Note: This is private and caller MUST hold self._session_lock.
"""
# Close existing session if any
if self._session is not None:
try:
await self._session.close()
except Exception as e: # pragma: no cover
logger.warning(f"Error closing previous session: {e}")
finally:
self._session = None
# Snapshot and clear old session reference before creating the new
# one. This ensures self._session is always valid (or None, which
# triggers a fresh creation) and avoids a race where concurrent
# requests hold a reference to a session whose connector has been
# torn down by a premature close() call — the root cause of the
# intermittent "NoneType has no attribute connect" crash.
old_session = self._session
self._session = None
# Check for app-level proxy settings
proxy_url = None # http(s) proxy, passed via the per-request `proxy=` kwarg
@@ -348,6 +372,13 @@ class Downloader:
self._proxy_url = proxy_url
self._session_created_at = datetime.now()
# Close the previous session now that the replacement is live.
if old_session is not None:
try:
await old_session.close()
except Exception as e: # pragma: no cover
logger.warning(f"Error closing previous session: {e}")
logger.debug(
"Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s",
bool(proxy_url),
@@ -729,7 +760,8 @@ class Downloader:
else:
resume_offset = 0
total_size = 0
await self._create_session()
async with self._session_lock:
await self._create_session()
continue
return False, integrity_error
@@ -819,7 +851,8 @@ class Downloader:
logger.info(f"Will resume from byte {resume_offset}")
# Refresh session to get new connection
await self._create_session()
async with self._session_lock:
await self._create_session()
continue
else:
logger.error(f"Max retries exceeded for download: {e}")
@@ -911,6 +944,19 @@ class Downloader:
elif response.status == 404:
error_msg = "File not found"
return False, error_msg, None
elif response.status == 429:
raw_retry_after = response.headers.get("Retry-After")
retry_after = _parse_retry_after(raw_retry_after or "")
if raw_retry_after:
logger.warning(
"Rate limited (429) for %s, Retry-After: %ss", url, retry_after
)
else:
logger.warning(
"Rate limited (429) for %s, no Retry-After header; defaulting to %ss",
url, retry_after,
)
return False, f"Rate limited (429), retry after {retry_after}s", None
else:
error_msg = f"Download failed with status {response.status}"
return False, error_msg, None
+442 -68
View File
@@ -19,11 +19,201 @@ from .errors import LLMNotConfiguredError, LLMRateLimitError, LLMResponseError
logger = logging.getLogger(__name__)
# Default API base URLs per provider
# ---------------------------------------------------------------------------
# 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] = {
"openai": "https://api.openai.com/v1",
"ollama": "http://localhost:11434/v1",
# "custom" requires an explicit llm_api_base from the user
pid: info["api_base"]
for pid, info in PROVIDER_PRESETS.items()
if info.get("api_base")
}
# Request timeout for LLM calls (seconds)
@@ -33,8 +223,8 @@ _LLM_TIMEOUT = aiohttp.ClientTimeout(total=120)
class LLMService:
"""Centralized LLM API client.
All agent skills call LLMs through this service so that BYOK config,
retry logic, and error handling live in one place.
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
@@ -57,6 +247,10 @@ class LLMService:
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
@@ -79,20 +273,33 @@ class LLMService:
"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 and
(for non-Ollama) an API key is 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 cfg["provider"] == "ollama"
return has_model and has_key
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."""
"""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("/")
@@ -109,19 +316,26 @@ class LLMService:
def _ensure_configured(self) -> Dict[str, Any]:
"""Validate configuration and return it, or raise.
A provider is considered configured when ``llm_model`` is set and
(for non-Ollama) an API key is configured.
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"])
has_key = bool(cfg["api_key"]) or cfg["provider"] == "ollama"
if not (has_model and has_key):
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 cfg["provider"] != "ollama":
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."
@@ -163,18 +377,68 @@ class LLMService:
cfg = self._ensure_configured()
api_base = self._resolve_api_base(cfg["provider"], cfg["api_base"])
url = f"{api_base}/chat/completions"
model_name = model or cfg["model"]
payload: Dict[str, Any] = {
"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
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"])
@@ -217,8 +481,25 @@ class LLMService:
# Parse response
try:
content = data["choices"][0]["message"]["content"]
usage = data.get("usage", {})
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,
@@ -245,11 +526,15 @@ class LLMService:
temperature: float = 0.3,
max_tokens: Optional[int] = None,
) -> Dict[str, Any]:
"""Call the LLM and return parsed JSON.
"""Call the LLM with ``response_format=json_object`` and return parsed JSON.
Sends ``response_format: {"type": "json_object"}`` when the provider
supports it, and parses the response content as JSON. If parsing
fails, retries once with a clarifying system message.
``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
@@ -264,7 +549,7 @@ class LLMService:
Raises:
LLMNotConfiguredError: Provider not configured
LLMRateLimitError: Rate limited
LLMResponseError: JSON parse failure after retry
LLMResponseError: Empty response or JSON parse failure
"""
messages = [
@@ -272,50 +557,139 @@ class LLMService:
{"role": "user", "content": user_prompt},
]
# First attempt with JSON mode
# 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=max_tokens,
max_tokens=effective_max,
)
try:
return json.loads(result["content"])
except (json.JSONDecodeError, TypeError) as exc:
logger.warning(
"LLM JSON parse failed on first attempt: %s. Retrying.", exc
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]}"
)
# Retry with explicit instruction to return valid JSON
retry_messages = messages + [
{
"role": "assistant",
"content": result["content"],
},
{
"role": "user",
"content": (
"The previous response could not be parsed as JSON. "
"Please respond with ONLY a valid JSON object, no "
"markdown fences or extra text."
),
},
]
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],
)
result = await self.chat_completion(
messages=retry_messages,
model=model,
temperature=0.0, # More deterministic for retry
response_format={"type": "json_object"},
max_tokens=max_tokens,
# 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]}"
)
try:
return json.loads(result["content"])
except (json.JSONDecodeError, TypeError) as exc:
raise LLMResponseError(
f"LLM response could not be parsed as JSON after retry: {exc}\n"
f"Raw content: {result['content'][:500]}"
) from exc
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
+7 -3
View File
@@ -271,12 +271,16 @@ class LoraService(BaseModelService):
return letters
async def get_lora_trigger_words(self, lora_name: str) -> List[str]:
"""Get trigger words for a specific LoRA file"""
"""Get trigger words for a specific LoRA file.
Supports both simple names and full-path syntax.
"""
cache = await self.scanner.get_cached_data()
for lora in cache.raw_data:
if lora["file_name"] == lora_name:
civitai_data = lora.get("civitai", {})
file_name = lora.get("file_name", "")
if file_name == lora_name or lora_name.endswith("/" + file_name) or lora_name.endswith("\\" + file_name):
civitai_data = lora.get("civitai") or {}
return civitai_data.get("trainedWords", [])
return []
+69 -16
View File
@@ -15,6 +15,17 @@ from .service_registry import ServiceRegistry
logger = logging.getLogger(__name__)
_PROVIDER_DISPLAY_NAMES = {
"civitai_api": "CivitAI",
"civarchive_api": "CivArchive",
"sqlite": "Archive DB",
}
_PRESET_PROVIDER_ORDERS = {
"civitai_archive_sqlite": ["civitai_api", "civarchive_api", "sqlite"],
"civitai_sqlite_archive": ["civitai_api", "sqlite", "civarchive_api"],
}
async def initialize_metadata_providers():
"""Initialize and configure all metadata providers based on settings"""
provider_manager = await ModelMetadataProviderManager.get_instance()
@@ -26,7 +37,9 @@ async def initialize_metadata_providers():
# Get settings
settings_manager = get_settings_manager()
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
providers = []
# Initialize archive database provider if enabled
@@ -59,27 +72,48 @@ async def initialize_metadata_providers():
except Exception as e:
logger.error(f"Failed to initialize Civitai API metadata provider: {e}")
# Register CivArchive provider, and all add to fallback providers
try:
civarchive_client = await ServiceRegistry.get_civarchive_client()
civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client)
provider_manager.register_provider('civarchive_api', civarchive_provider)
providers.append(('civarchive_api', civarchive_provider))
logger.debug("CivArchive metadata provider registered (also included in fallback)")
except Exception as e:
logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
# Register CivArchive provider when enabled. Civitai API is always
# preferred (better metadata); CivArchive mainly recovers metadata for
# models deleted from Civitai, so it can be turned off to avoid its long
# rate-limit windows entirely.
if enable_civarchive_api:
try:
civarchive_client = await ServiceRegistry.get_civarchive_client()
civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client)
provider_manager.register_provider('civarchive_api', civarchive_provider)
providers.append(('civarchive_api', civarchive_provider))
logger.debug("CivArchive metadata provider registered (also included in fallback)")
except Exception as e:
logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
else:
logger.debug("CivArchive metadata provider disabled by setting 'enable_civarchive_api'")
# Preset fallback orderings (see module-level _PRESET_PROVIDER_ORDERS).
# civitai_api is always first (better metadata); the remaining providers
# are arranged by the configured preset. Providers that are not
# registered (disabled/unavailable) are simply skipped, so each preset
# degrades gracefully.
desired_order = _PRESET_PROVIDER_ORDERS.get(
provider_order, _PRESET_PROVIDER_ORDERS["civitai_archive_sqlite"]
)
# Set up fallback provider based on available providers
if len(providers) > 1:
# Always use Civitai API (it has better metadata), then CivArchive API, then Archive DB
ordered_providers: list[tuple[str, ModelMetadataProvider]] = []
ordered_providers.extend([p for p in providers if p[0] == 'civitai_api'])
ordered_providers.extend([p for p in providers if p[0] == 'civarchive_api'])
ordered_providers.extend([p for p in providers if p[0] == 'sqlite'])
for name in desired_order:
ordered_providers.extend([p for p in providers if p[0] == name])
# Include any provider not covered by the preset (defensive) at the end
for p in providers:
if p not in ordered_providers:
ordered_providers.append(p)
if ordered_providers:
fallback_provider = FallbackMetadataProvider(ordered_providers)
provider_manager.register_provider('fallback', fallback_provider, is_default=True)
logger.debug(
"Metadata fallback provider order: %s",
", ".join(name for name, _ in ordered_providers),
)
elif len(providers) == 1:
# Only one provider available, set it as default
provider_name, provider = providers[0]
@@ -96,11 +130,30 @@ async def update_metadata_providers():
# Get current settings
settings_manager = get_settings_manager()
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
# Reinitialize all providers with new settings
provider_manager = await initialize_metadata_providers()
logger.info(f"Updated metadata providers, archive_db enabled: {enable_archive_db}")
# Build effective provider chain for logging (use actually-registered
# providers, not just settings, so a failed init is reflected correctly)
registered = set(provider_manager.providers.keys())
desired = _PRESET_PROVIDER_ORDERS.get(
provider_order, _PRESET_PROVIDER_ORDERS["civitai_archive_sqlite"]
)
chain = "".join(
_PROVIDER_DISPLAY_NAMES[p]
for p in desired
if p in registered and p in _PROVIDER_DISPLAY_NAMES
)
logger.info(
"Updated metadata providers: archive_db=%s, civarchive_api=%s, chain=%s",
enable_archive_db,
enable_civarchive_api,
chain,
)
return provider_manager
except Exception as e:
logger.error(f"Failed to update metadata providers: {e}")
+15 -1
View File
@@ -209,7 +209,21 @@ class MetadataSyncService:
error_msg = "CivitAI model is deleted and no archive provider is available"
return False, error_msg
else:
provider_attempts.append((None, await self._get_default_provider()))
is_hf_source = bool(model_data.get("hf_url"))
if is_hf_source:
# HF-sourced model: only check CivitAI API directly.
# CivArchive is almost guaranteed to have no record, and
# hitting it wastes rate-limit budget.
# Use a distinct provider name ("civitai_api" not None) so
# downstream code does NOT interpret a "Model not found"
# response as civitai_api_not_found — which would mark the
# model civitai_deleted=True when it was never on CivitAI.
try:
provider_attempts.append(("civitai_api", await self._get_provider("civitai_api")))
except Exception as exc: # pragma: no cover - provider resolution fault
logger.debug("Unable to resolve civitai_api provider: %s", exc)
if not provider_attempts:
provider_attempts.append((None, await self._get_default_provider()))
civitai_metadata: Optional[Dict[str, Any]] = None
metadata_provider: Optional[MetadataProviderProtocol] = None
+22 -1
View File
@@ -337,4 +337,25 @@ class ModelCache:
else:
return False # Model not found
return True
return True
async def clear_preview_by_path(self, preview_file_path: str) -> int:
"""Clear ``preview_url`` for every cached entry referencing a file path.
When a preview file has been deleted from disk, this removes its
reference from all matching cache entries so the next list-API
response returns an empty ``preview_url`` instead of a stale URL
that produces 404s.
Returns the number of entries that were updated.
"""
normalized = preview_file_path.replace("\\", "/")
cleared = 0
async with self._lock:
for item in self.raw_data:
cached_url = item.get("preview_url", "")
if cached_url.replace("\\", "/") == normalized:
item["preview_url"] = ""
item["preview_nsfw_level"] = 0
cleared += 1
return cleared
+243 -1
View File
@@ -227,6 +227,11 @@ class ModelScanner:
entry: Dict[str, Any] = {
'file_path': normalized_path,
# file_name is always stored WITHOUT extension (e.g. "OWSMianne_ANIMA_V1",
# not "OWSMianne_ANIMA_V1.safetensors"). All upstream population points
# (MetadataManager, from_civitai_info, download manager, etc.) strip the
# extension via os.path.splitext before writing. Code consuming this field
# should match against names that are likewise extension-free.
'file_name': get_value('file_name', '') or '',
'model_name': get_value('model_name', '') or '',
'folder': normalized_folder,
@@ -1561,6 +1566,218 @@ class ModelScanner:
return cache_entry if metadata else True
async def sync_cache_from_metadata(
self, file_path: str, metadata_dict: Dict[str, Any]
) -> bool:
"""Opportunistically sync in-memory and persistent caches from metadata.
Builds a prospective cache entry from *metadata_dict* (deserialized
``.metadata.json`` content) and compares it against the current cache
entry. When the two are already identical this method returns
``False`` without touching anything avoiding the overhead of
``update_single_model_cache``, which always removes and re-inserts
the entry, triggers a full resort, and persists via the heavyweight
``save_cache()``.
When differences are detected the update is applied **in-place** with
targeted operations:
* The existing ``raw_data`` entry is modified rather than removed and
re-appended (O(1) instead of O(n)).
* Tag counts and the hash index are updated incrementally.
* The version index is rebuilt only for the affected entry.
* ``resort()`` is called **only** when a sort-relevant field changed
(``model_name`` / ``file_name`` for name-sort, ``modified`` for
date-sort, ``size`` for size-sort).
* The persistent (SQLite) cache receives a targeted single-row update
via :meth:`PersistentModelCache.update_single_model` rather than a
full-table ``save_cache()``.
Returns:
``True`` if any cache update was performed, ``False`` if the
caches were already in sync.
.. note::
This is a **best-effort** operation. Failures are logged but
never propagated callers should fire-and-forget via
:func:`asyncio.create_task`.
"""
try:
return await self._sync_cache_from_metadata_impl(
file_path, metadata_dict
)
except Exception:
logger.warning(
"sync_cache_from_metadata failed for %s",
file_path,
exc_info=True,
)
return False
async def _sync_cache_from_metadata_impl(
self, file_path: str, metadata_dict: Dict[str, Any]
) -> bool:
cache = await self.get_cached_data()
# Locate the existing cache entry -----------------------------------
existing_idx: Optional[int] = None
existing_entry: Optional[Dict[str, Any]] = None
for i, item in enumerate(cache.raw_data):
if item.get("file_path") == file_path:
existing_entry = item
existing_idx = i
break
# Build the desired entry from metadata ------------------------------
folder_value = (
existing_entry.get("folder", "")
if existing_entry
else self._calculate_folder(file_path)
)
desired_entry = self._build_cache_entry(
metadata_dict,
folder=folder_value,
file_path_override=file_path,
)
# Ensure sha256 is populated (defensive — metadata should have it)
if (
not desired_entry.get("sha256")
and file_path
and os.path.exists(file_path)
):
try:
sha256 = await calculate_sha256(file_path)
if sha256:
desired_entry["sha256"] = sha256.lower()
except Exception:
pass
# Not in cache at all — delegate to the full update path ------------
if existing_entry is None:
result = await self.update_single_model_cache(
file_path, file_path, metadata_dict
)
return bool(result)
# Compare — skip everything if already in sync -----------------------
if not self._cache_entries_differ(existing_entry, desired_entry):
return False
# Re-validate: the cache may have been replaced concurrently
# (e.g. by _apply_scan_result). Use identity check, not equality,
# so we detect when the raw_data list was swapped out from under us.
if self._cache is None or not any(
item is existing_entry for item in self._cache.raw_data
):
return False
# ---- Differences detected: apply targeted, in-place updates --------
# Snapshot old values for delta computations
old_tags = list(existing_entry.get("tags") or [])
old_sha256: str = existing_entry.get("sha256", "") or ""
old_model_name: str = existing_entry.get("model_name", "") or ""
old_file_name: str = existing_entry.get("file_name", "") or ""
old_modified: float = float(existing_entry.get("modified", 0.0) or 0.0)
old_size: int = int(existing_entry.get("size", 0) or 0)
old_civitai = existing_entry.get("civitai")
# ---- In-place update of the cache entry ----
existing_entry.clear()
existing_entry.update(desired_entry)
# ---- Incremental tag count update ----
new_tags: set = set(desired_entry.get("tags") or [])
old_tag_set: set = set(old_tags)
for tag in old_tag_set - new_tags:
current = self._tags_count.get(tag, 0)
if current <= 1:
self._tags_count.pop(tag, None)
else:
self._tags_count[tag] = current - 1
for tag in new_tags - old_tag_set:
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
# ---- Incremental hash index update ----
new_sha = (desired_entry.get("sha256", "") or "").lower()
old_sha = (old_sha256 or "").lower()
if new_sha != old_sha:
if old_sha:
self._hash_index.remove_by_path(file_path)
if new_sha:
self._hash_index.add_entry(new_sha, file_path)
# ---- Incremental version index update ----
new_civitai = desired_entry.get("civitai")
if old_civitai != new_civitai:
temp_old = {
"file_path": file_path,
"file_name": old_file_name,
"civitai": old_civitai,
}
cache.remove_from_version_index(temp_old)
cache.add_to_version_index(existing_entry)
# ---- Conditional resort (only when sort-key fields changed) ----
need_resort = False
_last = cache._last_sort
sort_key: Optional[str] = _last[0] if _last != (None, None) else None
if sort_key == "name":
if (
old_model_name != desired_entry.get("model_name", "")
or old_file_name != desired_entry.get("file_name", "")
):
need_resort = True
elif sort_key == "date":
if old_modified != float(desired_entry.get("modified", 0.0) or 0.0):
need_resort = True
elif sort_key == "size":
if old_size != int(desired_entry.get("size", 0) or 0):
need_resort = True
if need_resort:
await cache.resort()
# ---- Targeted SQL update (single row, not full save_cache) ----
persistent = getattr(self, "_persistent_cache", None)
if persistent is not None:
old_item_for_sql: Dict[str, Any] = {
"file_path": file_path,
"tags": old_tags,
"sha256": old_sha256,
}
await asyncio.get_event_loop().run_in_executor(
None,
persistent.update_single_model,
self.model_type,
desired_entry,
old_item_for_sql,
)
return True
@staticmethod
def _cache_entries_differ(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
"""Return ``True`` when two cache-entry dicts differ in any field.
Tag lists are compared order-insensitively; all other keys use
standard equality.
"""
a_tags = sorted(a.get("tags") or [])
b_tags = sorted(b.get("tags") or [])
if a_tags != b_tags:
return True
all_keys = set(a.keys()) | set(b.keys())
for key in all_keys:
if key == "tags":
continue
if a.get(key) != b.get(key):
return True
return False
def has_hash(self, sha256: str) -> bool:
"""Check if a model with given hash exists"""
return self._hash_index.has_hash(sha256.lower())
@@ -1613,7 +1830,32 @@ class ModelScanner:
if limit == 0:
return sorted_tags
return sorted_tags[:limit]
async def search_tags(
self, query: str, limit: int = 50
) -> List[Dict[str, any]]:
"""Search tags by case-insensitive substring match, sorted by count.
If query is empty, behaves like get_top_tags (returns top ``limit``
tags). If limit is 0, all matching tags are returned.
"""
await self.get_cached_data()
normalized_query = (query or "").strip().lower()
if not normalized_query:
return await self.get_top_tags(limit if limit > 0 else 20)
matched = [
{"tag": tag, "count": count}
for tag, count in self._tags_count.items()
if normalized_query in tag.lower()
]
matched.sort(key=lambda x: x["count"], reverse=True)
if limit == 0:
return matched
return matched[:limit]
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
"""Get base models sorted by count. If limit is 0, return all."""
cache = await self.get_cached_data()
+89
View File
@@ -587,6 +587,95 @@ class PersistentModelCache:
placeholders = ", ".join(["?"] * len(self._MODEL_COLUMNS))
return f"INSERT INTO models ({columns}) VALUES ({placeholders})"
def update_single_model(
self,
model_type: str,
new_item: Dict,
old_item: Optional[Dict] = None,
) -> None:
"""Update a single model row in the persistent cache.
A lightweight alternative to :meth:`save_cache` that performs a targeted
DELETE + INSERT for the model row and computes incremental tag / hash-index
deltas from *old_item*. When *old_item* is omitted the previous tags and
hash are not cleaned up (callers should only omit it for brand-new entries).
All operations run inside a single transaction so readers see a consistent
view.
"""
if not self.is_enabled():
return
if not self._schema_initialized:
self._initialize_schema()
if not self._schema_initialized:
return
file_path: Optional[str] = new_item.get("file_path")
if not file_path:
return
try:
with self._db_lock:
conn = self._connect()
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
# --- model row (DELETE + INSERT = upsert) ---
conn.execute(
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
(model_type, file_path),
)
row = self._prepare_model_row(model_type, new_item)
conn.execute(self._insert_model_sql(), row)
# --- tags ---
new_tags: set = set(new_item.get("tags") or [])
old_tags: set = set(old_item.get("tags") or []) if old_item else set()
tags_to_delete = old_tags - new_tags
tags_to_insert = new_tags - old_tags
if tags_to_delete:
conn.executemany(
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
[(model_type, file_path, t) for t in tags_to_delete],
)
if tags_to_insert:
conn.executemany(
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
[(model_type, file_path, t) for t in tags_to_insert],
)
# --- hash_index ---
new_sha: Optional[str] = (new_item.get("sha256") or "").lower() or None
old_sha: Optional[str] = (
(old_item.get("sha256") or "").lower() or None
) if old_item else None
if new_sha != old_sha:
if old_sha:
conn.execute(
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
(model_type, old_sha, file_path),
)
if new_sha:
conn.execute(
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
(model_type, new_sha, file_path),
)
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
finally:
conn.close()
except Exception as exc:
logger.warning(
"Failed to update single model in persistent cache (%s): %s",
file_path,
exc,
)
def _load_tags(self, conn: sqlite3.Connection, model_type: str) -> Dict[str, List[str]]:
tag_rows = conn.execute(
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
+6 -2
View File
@@ -1,7 +1,6 @@
import asyncio
from typing import Iterable, List, Dict, Optional
from dataclasses import dataclass, field
from operator import itemgetter
from natsort import natsorted
@@ -149,5 +148,10 @@ class RecipeCache:
)
if not name_only:
self.sorted_by_date = sorted(
self.raw_data, key=itemgetter("created_date", "file_path"), reverse=True
self.raw_data,
key=lambda x: (
x.get("modified", x.get("created_date", 0)),
x.get("file_path", ""),
),
reverse=True,
)
+2 -1
View File
@@ -216,11 +216,12 @@ class RecipePersistenceService:
"preview_nsfw_level",
"favorite",
"gen_params",
"base_model",
)
if not any(key in updates for key in allowed_fields):
raise RecipeValidationError(
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params)"
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params or base_model)"
)
if "gen_params" in updates and not isinstance(updates["gen_params"], dict):
+56 -1
View File
@@ -65,6 +65,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"onboarding_completed": False,
"dismissed_banners": [],
"enable_metadata_archive_db": False,
"enable_civarchive_api": True,
"metadata_provider_order": "civitai_archive_sqlite",
"proxy_enabled": False,
"proxy_host": "",
"proxy_port": "",
@@ -152,6 +154,11 @@ class SettingsManager:
self._check_environment_variables()
self._collect_configuration_warnings()
if os.environ.get("LORA_MANAGER_PORTABLE", "0") == "1":
if not self.settings.get("use_portable_settings"):
self.settings["use_portable_settings"] = True
self._save_settings()
if self._needs_initial_save:
self._save_settings()
self._needs_initial_save = False
@@ -625,12 +632,37 @@ class SettingsManager:
return False
@staticmethod
def _normalize_path_set(paths: Iterable[str]) -> set[str]:
"""Normalize an iterable of paths for set-based overlap comparison.
Resolves symlinks via ``os.path.realpath`` when the path exists on disk,
then applies ``os.path.normcase`` + ``os.path.normpath`` for consistent
cross-platform comparison. Non-string / empty entries are skipped.
"""
result: set[str] = set()
for p in paths:
if not isinstance(p, str):
continue
stripped = p.strip()
if not stripped:
continue
if os.path.exists(stripped):
stripped = os.path.normpath(os.path.realpath(stripped))
result.add(os.path.normcase(stripped))
return result
def _validate_folder_paths(
self,
library_name: str,
folder_paths: Mapping[str, Iterable[str]],
) -> None:
"""Ensure folder paths do not overlap with other libraries."""
"""Ensure folder paths do not overlap with other libraries.
Also detects checkpoints unet path overlap within the same library
(including via symlink resolution), which is a configuration error since
these model types must use separate physical folders.
"""
libraries = self.settings.get("libraries", {})
normalized_new: Dict[str, Dict[str, str]] = {}
for key, values in folder_paths.items():
@@ -668,6 +700,22 @@ class SettingsManager:
f"Folder path(s) {collisions} already assigned to library '{other_name}'"
)
# Checkpoints ↔ unet overlap within the same library
ckpt_paths = folder_paths.get("checkpoints", []) or []
unet_paths = folder_paths.get("unet", []) or []
if ckpt_paths and unet_paths:
ckpt_real = self._normalize_path_set(ckpt_paths)
unet_real = self._normalize_path_set(unet_paths)
overlap = ckpt_real & unet_real
if overlap:
collisions = ", ".join(sorted(overlap))
raise ValueError(
f"Path(s) {collisions} are configured for both "
f"'checkpoints' and 'unet' (diffusion models). "
f"These model types must use separate physical folders. "
f"Please remove one of the conflicting entries."
)
def _update_active_library_entry(
self,
*,
@@ -1542,8 +1590,12 @@ class SettingsManager:
portable_switch_pending = True
self._prepare_portable_switch(value)
if key == "folder_paths" and isinstance(value, Mapping):
active_name = self.get_active_library_name()
self._validate_folder_paths(active_name, value)
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
elif key == "extra_folder_paths" and isinstance(value, Mapping):
active_name = self.get_active_library_name()
self._validate_folder_paths(active_name, value)
self._update_active_library_entry(extra_folder_paths=value) # type: ignore[arg-type]
elif key == "default_lora_root":
self._update_active_library_entry(default_lora_root=str(value))
@@ -1797,6 +1849,9 @@ class SettingsManager:
if key in self.settings:
minimal[key] = copy.deepcopy(self.settings[key])
if self.settings.get("use_portable_settings"):
minimal["use_portable_settings"] = True
if self._seed_template:
for key, value in self._seed_template.items():
minimal.setdefault(key, copy.deepcopy(value))
@@ -51,6 +51,10 @@ class BulkMetadataRefreshUseCase:
if not model.get("skip_metadata_refresh", False)
and not self._is_in_skip_path(model.get("folder", ""), skip_paths)
and (not model.get("civitai") or not model["civitai"].get("id"))
# Skip models downloaded from Hugging Face — they are not on
# CivitAI / CivArchive. Users can still refresh them individually
# via the right-click context menu.
and not model.get("hf_url", "")
and not (
# Skip models confirmed not on CivitAI when no need to retry
model.get("from_civitai") is False
+13 -1
View File
@@ -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",
]
)
+81 -24
View File
@@ -72,6 +72,7 @@ class _DownloadProgress(dict):
refreshed_models=set(),
failed_models=set(),
reprocessed_models=set(),
rate_limited_models=set(),
)
def snapshot(self) -> dict:
@@ -82,6 +83,7 @@ class _DownloadProgress(dict):
snapshot["refreshed_models"] = list(self["refreshed_models"])
snapshot["failed_models"] = list(self["failed_models"])
snapshot["reprocessed_models"] = list(self.get("reprocessed_models", set()))
snapshot["rate_limited_models"] = list(self.get("rate_limited_models", set()))
return snapshot
@@ -153,13 +155,15 @@ class DownloadManager:
# Step 3: Load progress file (I/O operation, done outside lock)
processed_models = set()
failed_models = set()
rate_limited_models = set()
try:
progress_file, processed_models, failed_models = await self._load_progress_file(output_dir)
progress_file, processed_models, failed_models, rate_limited_models = await self._load_progress_file(output_dir)
logger.debug(
"Loaded previous progress, %s models already processed, %s models marked as failed",
"Loaded previous progress, %s models already processed, %s models marked as failed, %s models rate-limited",
len(processed_models),
len(failed_models),
len(rate_limited_models),
)
except Exception as e:
logger.error(f"Failed to load progress file: {e}")
@@ -175,6 +179,7 @@ class DownloadManager:
self._progress.reset()
self._progress["processed_models"] = processed_models
self._progress["failed_models"] = failed_models
self._progress["rate_limited_models"] = rate_limited_models
self._stop_requested = False
self._progress["status"] = "running"
self._progress["start_time"] = time.time()
@@ -242,8 +247,8 @@ class DownloadManager:
"status": self._progress.snapshot(),
}
async def _load_progress_file(self, output_dir: str) -> tuple[str, set, set]:
"""Load progress file from disk. Returns (progress_file_path, processed_models, failed_models).
async def _load_progress_file(self, output_dir: str) -> tuple[str, set, set, set]:
"""Load progress file from disk. Returns (progress_file_path, processed_models, failed_models, rate_limited_models).
This is a separate async method to allow running in executor to avoid blocking event loop.
"""
@@ -252,8 +257,12 @@ class DownloadManager:
None, self._load_progress_file_sync, output_dir
)
def _load_progress_file_sync(self, output_dir: str) -> tuple[str, set, set]:
"""Synchronous implementation of progress file loading."""
def _load_progress_file_sync(self, output_dir: str) -> tuple[str, set, set, set]:
"""Synchronous implementation of progress file loading.
Returns:
tuple: (progress_file_path, processed_models, failed_models, rate_limited_models)
"""
progress_file = os.path.join(output_dir, ".download_progress.json")
progress_source = progress_file
@@ -289,6 +298,7 @@ class DownloadManager:
processed_models = set()
failed_models = set()
rate_limited_models = set()
if os.path.exists(progress_source):
try:
@@ -296,11 +306,11 @@ class DownloadManager:
saved_progress = json.load(f)
processed_models = set(saved_progress.get("processed_models", []))
failed_models = set(saved_progress.get("failed_models", []))
rate_limited_models = set(saved_progress.get("rate_limited_models", []))
except Exception:
# Return empty sets on error
pass
return progress_file, processed_models, failed_models
return progress_file, processed_models, failed_models, rate_limited_models
def _load_progress_sets_sync(self, progress_file: str) -> tuple[set, set]:
"""Load only the processed and failed model sets from progress file.
@@ -732,11 +742,13 @@ class DownloadManager:
success,
is_stale,
failed_images,
rate_limited_images,
) = await ExampleImagesProcessor.download_model_images_with_tracking(
model_hash, model_name, images, model_dir, optimize, downloader
)
failed_urls: Set[str] = set(failed_images)
rate_limited_urls: Set[str] = set(rate_limited_images)
# If metadata is stale, try to refresh it
if is_stale and model_hash not in self._progress["refreshed_models"]:
@@ -760,6 +772,7 @@ class DownloadManager:
success,
_,
additional_failed,
additional_rate_limited,
) = await ExampleImagesProcessor.download_model_images_with_tracking(
model_hash,
model_name,
@@ -770,29 +783,50 @@ class DownloadManager:
)
failed_urls.update(additional_failed)
rate_limited_urls.update(additional_rate_limited)
self._progress["refreshed_models"].add(model_hash)
if failed_urls:
# Separate permanent failures from rate-limited ones
permanent_failures = failed_urls - rate_limited_urls
if permanent_failures:
await self._remove_failed_images_from_metadata(
model_hash,
model_name,
model_dir,
failed_urls,
permanent_failures,
scanner,
)
if failed_urls:
if rate_limited_urls:
self._progress["rate_limited_models"].add(model_hash)
logger.warning(
"%d example images for %s are rate-limited (429), will retry next time",
len(rate_limited_urls),
model_name,
)
# Clear failed_models so non-force runs can retry
if force and model_hash in self._progress["failed_models"]:
self._progress["failed_models"].discard(model_hash)
logger.info(
f"Removed {model_name} from failed_models after force retry with rate-limited images"
)
if rate_limited_urls:
# Don't mark as failed or fully processed — rate-limited
# images will be retried next time.
pass
elif permanent_failures:
self._progress["failed_models"].add(model_hash)
self._progress["processed_models"].add(model_hash)
logger.info(
"Removed %s failed example images for %s",
len(failed_urls),
len(permanent_failures),
model_name,
)
elif success:
self._progress["processed_models"].add(model_hash)
# Remove from failed_models if force mode enabled and model was previously failed
if force and model_hash in self._progress["failed_models"]:
self._progress["failed_models"].discard(model_hash)
logger.info(
@@ -850,6 +884,7 @@ class DownloadManager:
"processed_models": list(self._progress["processed_models"]),
"refreshed_models": list(self._progress["refreshed_models"]),
"failed_models": list(self._progress["failed_models"]),
"rate_limited_models": list(self._progress.get("rate_limited_models", set())),
"completed": self._progress["completed"],
"total": self._progress["total"],
"last_update": time.time(),
@@ -1155,11 +1190,13 @@ class DownloadManager:
success,
is_stale,
failed_images,
rate_limited_images,
) = await ExampleImagesProcessor.download_model_images_with_tracking(
model_hash, model_name, images, model_dir, optimize, downloader
)
failed_urls: Set[str] = set(failed_images)
rate_limited_urls: Set[str] = set(rate_limited_images)
# If metadata is stale, try to refresh it
if is_stale and model_hash not in self._progress["refreshed_models"]:
@@ -1183,6 +1220,7 @@ class DownloadManager:
success,
_,
additional_failed_images,
additional_rate_limited,
) = await ExampleImagesProcessor.download_model_images_with_tracking(
model_hash,
model_name,
@@ -1192,21 +1230,35 @@ class DownloadManager:
downloader,
)
# Combine failed images from both attempts
failed_urls.update(additional_failed_images)
rate_limited_urls.update(additional_rate_limited)
self._progress["refreshed_models"].add(model_hash)
# For forced downloads, remove failed images from metadata
if failed_urls:
# Separate permanent failures from rate-limited ones
permanent_failures = failed_urls - rate_limited_urls
# Only remove permanently failed images from metadata
if permanent_failures:
await self._remove_failed_images_from_metadata(
model_hash, model_name, model_dir, failed_urls, scanner
model_hash, model_name, model_dir, permanent_failures, scanner
)
# Mark as processed
if (
success or failed_urls
): # Mark as processed if we successfully downloaded some images or removed failed ones
if rate_limited_urls:
self._progress["rate_limited_models"].add(model_hash)
logger.warning(
"%d example images for %s are rate-limited (429), will retry next time",
len(rate_limited_urls),
model_name,
)
# Mark as processed only when no rate-limited images remain
if rate_limited_urls:
pass
elif permanent_failures:
self._progress["processed_models"].add(model_hash)
self._progress["failed_models"].add(model_hash)
elif success:
self._progress["processed_models"].add(model_hash)
return True # Return True to indicate a remote download happened
@@ -1229,15 +1281,20 @@ class DownloadManager:
model_dir: str,
failed_images: Iterable[str],
scanner,
error_type: str = "not_found",
) -> None:
"""Mark failed images in model metadata so they won't be retried."""
"""Mark failed images in model metadata so they won't be retried.
Args:
error_type: Reason string stored in the image's ``downloadError`` field
(default ``"not_found"``).
"""
failed_set: Set[str] = {url for url in failed_images if url}
if not failed_set:
return
try:
# Get current model data
model_data = await MetadataUpdater.get_updated_model(model_hash, scanner)
if not model_data:
logger.warning(
@@ -1268,7 +1325,7 @@ class DownloadManager:
continue
image["downloadFailed"] = True
image.setdefault("downloadError", "not_found")
image.setdefault("downloadError", error_type)
logger.debug(
"Marked example image %s for %s as failed due to missing remote asset",
image_url,
+29
View File
@@ -113,6 +113,35 @@ def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str
exc,
)
return legacy_folder
elif not os.path.exists(resolved_folder):
# Reverse migration: when consolidating from multi-library to
# single-library mode (e.g. after "default" was cleaned up), look
# for existing example images inside library-named subdirectories
# and bring them back to the root level.
root = get_example_images_root()
if root:
try:
for entry in os.listdir(root):
entry_path = os.path.join(root, entry)
if not os.path.isdir(entry_path):
continue
if is_hash_folder(entry) or entry == "_deleted":
continue
if not _library_folder_has_only_hash_dirs(entry_path):
continue
legacy = os.path.join(entry_path, normalized_hash)
if os.path.exists(legacy):
shutil.move(legacy, resolved_folder)
logger.info(
"Consolidated example images from '%s' to '%s'",
legacy, resolved_folder,
)
break
except OSError as exc:
logger.error(
"Failed to consolidate example images during "
"library merge: %s", exc,
)
return resolved_folder
+92 -39
View File
@@ -1,3 +1,4 @@
import asyncio
import logging
import os
import re
@@ -194,16 +195,22 @@ class ExampleImagesProcessor:
return model_success, False # (success, is_metadata_stale)
@staticmethod
def _extract_retry_after(error_message: str) -> int:
if not error_message:
return 60
match = re.search(r"retry after (\d+)s", str(error_message))
if match:
return max(1, int(match.group(1)))
return 60
@staticmethod
async def download_model_images_with_tracking(model_hash, model_name, model_images, model_dir, optimize, downloader):
"""Download images for a single model with tracking of failed image URLs
Returns:
tuple: (success, is_stale_metadata, failed_images) - whether download was successful, whether metadata is stale, list of failed image URLs
"""
model_success = True
failed_images = []
rate_limited_images = []
any_successful_download = False
for i, image in enumerate(model_images):
image_url = image.get('url')
if not image_url:
@@ -221,64 +228,110 @@ class ExampleImagesProcessor:
original_url = image_url
if optimize and 'civitai.com' in image_url:
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
# Download the file first to determine the actual file type
try:
logger.debug(f"Downloading media file {i} for {model_name}")
# Download using the unified downloader with headers
success, content, headers = await downloader.download_to_memory(
async def _attempt_download() -> tuple:
logger.debug("Downloading media file %s for %s", i, model_name)
return await downloader.download_to_memory(
image_url,
use_auth=False, # Example images don't need auth
return_headers=True
use_auth=False,
return_headers=True,
)
try:
success, content, headers = await _attempt_download()
if success:
# Determine file extension from content or headers
media_ext = ExampleImagesProcessor._get_file_extension_from_content_or_headers(
content, headers, original_url, image.get("type")
)
# Check if the detected file type is supported
is_image = media_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
is_video = media_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
if not (is_image or is_video):
logger.debug(f"Skipping unsupported file type: {media_ext}")
logger.debug("Skipping unsupported file type: %s", media_ext)
continue
# Use 0-based indexing with the detected extension
save_filename = f"image_{i}{media_ext}"
save_path = os.path.join(model_dir, save_filename)
# Check if already downloaded
if os.path.exists(save_path):
logger.debug(f"File already exists: {save_path}")
logger.debug("File already exists: %s", save_path)
continue
# Save the file
with open(save_path, 'wb') as f:
f.write(content)
any_successful_download = True
elif ExampleImagesProcessor._is_not_found_error(content):
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
logger.warning(error_msg)
model_success = False # Mark the model as failed due to 404 error
failed_images.append(image_url) # Track failed URL
# Return early to trigger metadata refresh attempt
return False, True, failed_images # (success, is_metadata_stale, failed_images)
model_success = False
failed_images.append(image_url)
return False, True, failed_images, rate_limited_images
elif "Rate limited (429)" in str(content):
max_attempts = 3
for attempt in range(1, max_attempts + 1):
wait = ExampleImagesProcessor._extract_retry_after(str(content)) * (2 ** (attempt - 1))
logger.warning(
"Rate limited (429) for %s, retry %d/%d after %ds",
image_url, attempt, max_attempts, wait,
)
await asyncio.sleep(wait)
success, content, headers = await _attempt_download()
if success:
media_ext = ExampleImagesProcessor._get_file_extension_from_content_or_headers(
content, headers, original_url, image.get("type")
)
is_image = media_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
is_video = media_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
if not (is_image or is_video):
logger.debug("Skipping unsupported file type: %s", media_ext)
break
save_filename = f"image_{i}{media_ext}"
save_path = os.path.join(model_dir, save_filename)
if os.path.exists(save_path):
logger.debug("File already exists: %s", save_path)
break
with open(save_path, 'wb') as f:
f.write(content)
any_successful_download = True
break
elif "Rate limited (429)" in str(content):
continue
elif ExampleImagesProcessor._is_not_found_error(content):
logger.warning("Failed to download file: %s, status code: 404", image_url)
model_success = False
failed_images.append(image_url)
break
else:
logger.warning("Failed to download file: %s, error: %s", image_url, content)
model_success = False
failed_images.append(image_url)
break
else:
logger.warning(
"Giving up on %s after %d retries due to rate limiting",
image_url, max_attempts,
)
rate_limited_images.append(image_url)
model_success = False
else:
error_msg = f"Failed to download file: {image_url}, error: {content}"
logger.warning(error_msg)
model_success = False # Mark the model as failed
failed_images.append(image_url) # Track failed URL
model_success = False
failed_images.append(image_url)
except Exception as e:
error_msg = f"Error downloading file {image_url}: {str(e)}"
logger.error(error_msg)
model_success = False # Mark the model as failed
failed_images.append(image_url) # Track failed URL
return model_success, False, failed_images # (success, is_metadata_stale, failed_images)
model_success = False
failed_images.append(image_url)
return any_successful_download or model_success, False, failed_images, rate_limited_images
@staticmethod
async def process_local_examples(model_file_path, model_file_name, model_name, model_dir, optimize):
+6 -1
View File
@@ -12,6 +12,7 @@ from platformdirs import user_config_dir
APP_NAME = "ComfyUI-LoRA-Manager"
_LM_PORTABLE_ENV = "LORA_MANAGER_PORTABLE"
_LOGGER = logging.getLogger(__name__)
@@ -100,7 +101,11 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
def _should_use_portable_settings(path: str, logger: logging.Logger) -> bool:
"""Return ``True`` when the repository settings file enables portable mode."""
"""Return ``True`` when the env var forces it or the settings file enables it."""
if os.environ.get(_LM_PORTABLE_ENV, "0") == "1":
logger.debug("Portable mode enabled via %s", _LM_PORTABLE_ENV)
return True
if not os.path.exists(path):
return False
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.1.6"
version = "1.1.8"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
+4
View File
@@ -1,6 +1,10 @@
import os
import sys
import json
# Ensure the script's directory is on sys.path so that py.* imports resolve
# regardless of the current working directory (e.g. when launched via
# ComfyUI's python_embeded from the ComfyUI root directory).
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from py.middleware.cache_middleware import cache_control
from py.middleware.error_middleware import api_json_error
from py.utils.settings_paths import ensure_settings_file
+6
View File
@@ -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);
}
+119 -4
View File
@@ -577,13 +577,14 @@
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: var(--transition-base);
transition: var(--transition-base), box-shadow var(--transition-fast), transform var(--transition-fast);
background: var(--bg-color);
}
.file-option:hover {
border-color: var(--lora-accent);
box-shadow: var(--shadow-sm);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.file-option.selected {
@@ -698,10 +699,25 @@
color: var(--lora-accent);
}
/* Batch Preview List */
/* BUG 1 FIX: Single scrollbar modal-content becomes a flex column so the
batch preview step can flex; the list scrolls instead of the modal-content. */
#downloadModal .modal-content {
display: flex;
flex-direction: column;
}
#batchPreviewStep {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
/* Batch Preview List — no max-height; flexes inside #batchPreviewStep */
.batch-preview-list {
max-height: 400px;
flex: 1;
overflow-y: auto;
min-height: 0;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
@@ -859,6 +875,8 @@
position: sticky;
top: 0;
z-index: 1;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.batch-preview-select-all input[type="checkbox"] {
@@ -884,3 +902,100 @@
[data-theme="dark"] .batch-preview-select-all {
background: var(--lora-surface);
}
/* FEATURE 2: HF repo grouping — collapsible groups by repo */
.batch-preview-group {
display: flex;
flex-direction: column;
background: var(--surface-base);
}
.batch-preview-group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--color-accent-subtle);
border-bottom: 1px solid var(--color-accent-border);
cursor: pointer;
user-select: none;
transition: background var(--transition-fast);
}
.batch-preview-group-header:hover {
background: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.18);
}
.batch-preview-group-toggle {
width: 14px;
font-size: 0.75em;
color: var(--text-color);
opacity: 0.7;
transition: transform var(--transition-fast);
flex-shrink: 0;
}
.batch-preview-group-toggle.expanded {
transform: rotate(90deg);
}
.batch-preview-group-name {
flex: 1;
min-width: 0;
font-weight: 600;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.95em;
}
.batch-preview-group-count {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
flex-shrink: 0;
}
.batch-preview-group-select-all {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--lora-accent);
flex-shrink: 0;
padding: 0;
margin: 0;
}
.batch-preview-group-body {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border-color);
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 0.35s ease, opacity 0.2s ease;
}
.batch-preview-group-body.expanded {
opacity: 1;
max-height: 9999px; /* rest state: content visible; JS inline style overrides during transitions */
}
/* Dark theme overrides for group styles */
[data-theme="dark"] .batch-preview-group {
background: var(--surface-base);
}
[data-theme="dark"] .batch-preview-group-header {
background: var(--color-accent-subtle);
}
[data-theme="dark"] .batch-preview-group-header:hover {
background: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.22);
}
[data-theme="dark"] .batch-preview-group-body {
background: var(--border-color);
}
@@ -21,18 +21,22 @@
margin-bottom: 4px;
}
.input-group {
#relinkCivitaiModal .input-group,
#linkHfModal .input-group {
display: flex;
flex-direction: column;
margin-bottom: var(--space-2);
}
.input-group label {
#relinkCivitaiModal .input-group label,
#linkHfModal .input-group label {
margin-bottom: var(--space-1);
font-weight: 500;
}
.input-group input {
#relinkCivitaiModal .input-group input,
#linkHfModal .input-group input {
width: auto;
padding: 8px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
@@ -1562,6 +1562,29 @@ input:checked + .toggle-slider:before {
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
}
.extra-folder-path-row .path-controls .extra-folder-path-input.has-error {
border-color: var(--lora-error);
background-color: rgba(220, 53, 69, 0.08);
background-color: rgba(from var(--lora-error) r g b / 0.08);
}
.extra-folder-path-row .path-controls .extra-folder-path-input.has-error:focus {
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.15);
box-shadow: 0 0 0 2px rgba(from var(--lora-error) r g b / 0.15);
}
.extra-folder-path-error {
color: var(--lora-error);
font-size: 0.8em;
margin-top: 4px;
line-height: 1.4;
display: none;
}
.extra-folder-path-error.visible {
display: block;
}
.extra-folder-path-row .path-controls .remove-path-btn {
width: 32px;
height: 32px;
@@ -1592,3 +1615,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 <body> by Combobox.js and positioned relative to
the enhanced <input>. Styles reuse settings-modal CSS variables. */
.lm-combobox-panel {
position: absolute;
z-index: 10002;
max-height: 240px;
overflow-y: auto;
background: var(--lora-surface, #2a2a2a);
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.12));
border-radius: var(--border-radius-xs, 6px);
box-shadow: var(--shadow-elevated, 0 6px 18px rgba(0, 0, 0, 0.45));
font-size: 0.95em;
color: var(--text-color, rgba(226, 232, 240, 0.9));
padding: 4px 0;
box-sizing: border-box;
}
.lm-combobox-option {
padding: 6px 12px;
cursor: pointer;
user-select: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lm-combobox-option:hover,
.lm-combobox-option.is-active {
background: rgba(from var(--lora-accent) r g b / 0.2);
color: var(--lora-accent);
}
.lm-combobox-empty {
padding: 8px 12px;
color: var(--text-color);
opacity: 0.45;
font-style: italic;
user-select: none;
}
+5
View File
@@ -274,6 +274,11 @@
font-style: italic;
}
/* Inline extra tags (selected but not in top-20/appended after API results) */
.filter-tag.extra-tag {
border-style: dashed;
}
/* Ensure solid border and full opacity when active or excluded */
.filter-tag.special-tag.active,
.filter-tag.special-tag.exclude {
+1
View File
@@ -93,6 +93,7 @@ export function getApiEndpoints(modelType) {
// Query operations
scan: `/api/lm/${modelType}/scan`,
topTags: `/api/lm/${modelType}/top-tags`,
searchTags: `/api/lm/${modelType}/search-tags`,
baseModels: `/api/lm/${modelType}/base-models`,
roots: `/api/lm/${modelType}/roots`,
folders: `/api/lm/${modelType}/folders`,
+12
View File
@@ -112,6 +112,18 @@ export class BaseModelApiClient {
}
}
async cancelDownload(downloadId) {
try {
const response = await fetch(
`${DOWNLOAD_ENDPOINTS.cancelGet}?download_id=${encodeURIComponent(downloadId)}`
);
return await response.json();
} catch (error) {
console.error('Error cancelling download:', error);
return { success: false, error: error.message };
}
}
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
const pageState = this.getPageState();
+394
View File
@@ -0,0 +1,394 @@
// Combobox.js — Reusable dropdown-suggestion + free-text input component.
//
// Enhances an existing <input> element with a dropdown panel that merges static
// `presets` with asynchronously fetched options (`fetchOptions`). The input
// remains a free-text field — selecting a dropdown option is optional, the
// user can always type an arbitrary value.
//
// Zero dependencies: pure DOM manipulation. Exported on `window.Combobox`
// so non-module callers can instantiate it, and as a named ES module export
// for callers that import it directly.
//
// Usage:
// const box = new Combobox(inputEl, {
// presets: ['masterpiece', 'best quality'],
// fetchOptions: async (q) => await fetchSuggestions(q),
// placeholder: 'Type a value…',
// onSelect: (value) => console.log('chose', value),
// });
// box.updatePresets(['new', 'presets']);
// box.setValue('masterpiece');
const DEBOUNCE_MS = 300;
export class Combobox {
/**
* @param {HTMLInputElement} inputElement Existing <input> to enhance.
* @param {Object} options
* @param {string[]} [options.presets=[]] Static preset values shown in dropdown.
* @param {(inputValue: string) => Promise<string[]>} [options.fetchOptions]
* Async function returning dynamic suggestions for the current input.
* @param {string} [options.placeholder] Placeholder text for the empty state.
* @param {(value: string) => void} [options.onSelect] Callback when an option is chosen.
*/
constructor(inputElement, options = {}) {
if (!inputElement || inputElement.tagName !== 'INPUT') {
console.error('Combobox: expected an <input> element');
return;
}
this.input = inputElement;
this.presets = Array.isArray(options.presets) ? [...options.presets] : [];
this.fetchOptions = typeof options.fetchOptions === 'function' ? options.fetchOptions : null;
this.placeholder = options.placeholder || '';
this.onSelect = typeof options.onSelect === 'function' ? options.onSelect : null;
// Internal state
this._isOpen = false;
this._activeIndex = -1;
this._renderedOptions = []; // current visible option strings (de-duplicated, merged)
this._fetchToken = 0; // guards against out-of-order async fetch results
this._fetchTimer = null;
this._suppressInputOpen = false; // guards setValue() from reopening the dropdown
this._buildDropdown();
this._bindEvents();
}
// ---- public API ----
/**
* Replace the preset list. Re-renders the dropdown if it is open.
* @param {string[]} presets
* @returns {void}
*/
updatePresets(presets) {
this.presets = Array.isArray(presets) ? [...presets] : [];
if (this._isOpen) {
this._refresh();
}
}
/**
* Set the input value programmatically without triggering the dropdown
* or firing synthetic events.
* @param {string} value
* @returns {void}
*/
setValue(value) {
const prev = this._suppressInputOpen;
this._suppressInputOpen = true;
this.input.value = value ?? '';
this._suppressInputOpen = prev;
if (this._isOpen) {
this._refresh();
}
}
// ---- build ----
_buildDropdown() {
const panel = document.createElement('div');
panel.className = 'lm-combobox-panel';
panel.setAttribute('role', 'listbox');
panel.style.display = 'none';
// Append to <body> so the panel is never clipped by an overflow:hidden
// ancestor; positioning is recomputed on each open.
document.body.appendChild(panel);
this.panel = panel;
if (this.placeholder) {
this.input.setAttribute('placeholder', this.placeholder);
}
this.input.setAttribute('autocomplete', 'off');
this.input.setAttribute('role', 'combobox');
this.input.setAttribute('aria-autocomplete', 'list');
this.input.setAttribute('aria-expanded', 'false');
}
// ---- event wiring ----
_bindEvents() {
this.input.addEventListener('focus', () => {
if (this._suppressInputOpen) return;
this._open();
});
this.input.addEventListener('input', () => {
if (this._suppressInputOpen) return;
this._open(); // no-op if already open
this._refresh(); // re-filter by current input value
this._scheduleFetch();
});
this.input.addEventListener('keydown', (event) => this._onKeyDown(event));
// Click an option (delegated)
this.panel.addEventListener('click', (event) => {
const item = event.target.closest('.lm-combobox-option');
if (!item) return;
const value = item.dataset.value;
if (value !== undefined) {
this._choose(value);
}
});
// Hover updates the active highlight so keyboard + mouse stay in sync.
this.panel.addEventListener('mouseover', (event) => {
const item = event.target.closest('.lm-combobox-option');
if (!item) return;
const idx = Number(item.dataset.index);
if (!Number.isNaN(idx)) {
this._setActiveIndex(idx);
}
});
// Click outside closes the dropdown.
this._outsideClickHandler = (event) => {
if (this._isOpen && !this.input.contains(event.target) && !this.panel.contains(event.target)) {
this._close();
}
};
document.addEventListener('mousedown', this._outsideClickHandler);
// Reposition on viewport changes while open.
this._resizeHandler = () => {
if (this._isOpen) this._position();
};
window.addEventListener('resize', this._resizeHandler);
window.addEventListener('scroll', this._resizeHandler, true);
}
// ---- keyboard ----
_onKeyDown(event) {
if (!this._isOpen) {
if (event.key === 'ArrowDown') {
event.preventDefault();
this._open();
this._setActiveIndex(0);
}
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this._setActiveIndex(this._activeIndex + 1);
break;
case 'ArrowUp':
event.preventDefault();
this._setActiveIndex(this._activeIndex - 1);
break;
case 'Enter':
// Only intercept Enter to pick an option when one is actively
// highlighted; otherwise let the input's default behavior
// (form submit / free-text commit) proceed.
if (this._activeIndex >= 0 && this._activeIndex < this._renderedOptions.length) {
event.preventDefault();
this._choose(this._renderedOptions[this._activeIndex]);
}
break;
case 'Escape':
event.preventDefault();
this._close();
this.input.focus();
break;
case 'Tab':
// Allow normal tab navigation; just close the panel.
this._close();
break;
}
}
// ---- open / close ----
_open() {
if (this._isOpen) return;
this._isOpen = true;
this.panel.style.display = 'block';
this.input.setAttribute('aria-expanded', 'true');
// On open, render ALL presets — do not filter by the current input
// value. Filtering on the input event is handled separately.
this._render(this.presets);
this._position();
}
_close() {
if (!this._isOpen) return;
this._isOpen = false;
this.panel.style.display = 'none';
this.input.setAttribute('aria-expanded', 'false');
this._activeIndex = -1;
this._cancelFetch();
}
_position() {
const rect = this.input.getBoundingClientRect();
const panelHeight = this.panel.offsetHeight;
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
// Flip above the input when there is more room there.
const placeAbove = spaceBelow < panelHeight && spaceAbove > spaceBelow;
const top = placeAbove
? rect.top + window.scrollY - panelHeight
: rect.bottom + window.scrollY;
this.panel.style.top = `${Math.max(0, top)}px`;
this.panel.style.left = `${rect.left + window.scrollX}px`;
this.panel.style.minWidth = `${rect.width}px`;
}
// ---- rendering ----
/** Render a list of strings into the panel. */
_render(items) {
this._renderedOptions = items;
this.panel.innerHTML = '';
if (items.length === 0) {
const empty = document.createElement('div');
empty.className = 'lm-combobox-empty';
empty.textContent = this.placeholder ? this.placeholder : 'No options';
this.panel.appendChild(empty);
this._activeIndex = -1;
return;
}
const fragment = document.createDocumentFragment();
items.forEach((opt, idx) => {
const item = document.createElement('div');
item.className = 'lm-combobox-option';
item.setAttribute('role', 'option');
item.dataset.value = opt;
item.dataset.index = String(idx);
item.textContent = opt;
if (idx === this._activeIndex) {
item.classList.add('is-active');
}
fragment.appendChild(item);
});
this.panel.appendChild(fragment);
if (this._activeIndex >= items.length) {
this._setActiveIndex(items.length - 1);
}
}
/** Filter presets by current input value and re-render. */
_refresh() {
const value = this.input.value;
const filtered = this._filterPresets(value);
const merged = this._mergeUnique(filtered, this._fetchedOptions || []);
this._render(merged);
}
_filterPresets(value) {
const v = (value || '').toLowerCase();
if (!v) return [...this.presets];
return this.presets.filter((p) => String(p).toLowerCase().startsWith(v));
}
_mergeUnique(...lists) {
const seen = new Set();
const out = [];
for (const list of lists) {
for (const item of list) {
const key = String(item);
if (!seen.has(key)) {
seen.add(key);
out.push(key);
}
}
}
return out;
}
_setActiveIndex(idx) {
const max = this._renderedOptions.length - 1;
const clamped = Math.max(-1, Math.min(max, idx));
this._activeIndex = clamped;
// Update DOM classes without full re-render.
const items = this.panel.querySelectorAll('.lm-combobox-option');
items.forEach((el, i) => {
el.classList.toggle('is-active', i === clamped);
});
// Scroll the active item into view inside the panel.
if (clamped >= 0 && items[clamped]) {
items[clamped].scrollIntoView({ block: 'nearest' });
}
}
/**
* Remove the panel from the DOM and detach event listeners.
* Call this before discarding the Combobox instance.
*/
destroy() {
this._close();
if (this.panel && this.panel.parentNode) {
this.panel.parentNode.removeChild(this.panel);
}
document.removeEventListener('mousedown', this._outsideClickHandler);
window.removeEventListener('resize', this._resizeHandler);
window.removeEventListener('scroll', this._resizeHandler, true);
}
_choose(value) {
this.input.value = value;
this._close();
if (typeof this.onSelect === 'function') {
this.onSelect(value);
}
// Re-focus without reopening the dropdown.
this._suppressInputOpen = true;
this.input.focus();
this._suppressInputOpen = false;
}
// ---- async fetch (debounced) ----
_scheduleFetch() {
if (!this.fetchOptions) return;
this._cancelFetch();
this._fetchTimer = setTimeout(() => {
this._fetchTimer = null;
this._runFetch();
}, DEBOUNCE_MS);
}
_cancelFetch() {
if (this._fetchTimer) {
clearTimeout(this._fetchTimer);
this._fetchTimer = null;
}
this._fetchToken++; // invalidate any in-flight result
}
async _runFetch() {
if (!this.fetchOptions) return;
const token = this._fetchToken;
const value = this.input.value;
let results;
try {
results = await this.fetchOptions(value);
} catch (err) {
console.error('Combobox fetchOptions error:', err);
results = [];
}
// Stale guard: a newer fetch or close superseded this one.
if (token !== this._fetchToken || !this._isOpen) return;
this._fetchedOptions = Array.isArray(results) ? results : [];
this._refresh();
}
}
// Expose for non-module callers (templates load via <script type="module">,
// but some widget code reads globals off `window`).
if (typeof window !== 'undefined') {
window.Combobox = Combobox;
}
@@ -27,8 +27,9 @@ export class BaseContextMenu {
const menuItem = e.target.closest('.context-menu-item');
if (!menuItem || !this.currentCard) return;
// Ignore clicks on submenu trigger (has-submenu parent)
// Ignore clicks on submenu trigger (has-submenu parent) or disabled items
if (menuItem.classList.contains('has-submenu')) return;
if (menuItem.classList.contains('disabled')) return;
const action = menuItem.dataset.action;
if (!action) return;
@@ -274,7 +274,7 @@ export class BulkContextMenu extends BaseContextMenu {
case 'resume-metadata-refresh':
bulkManager.setSkipMetadataRefresh(false);
break;
case 'enrich-hf-agent-bulk':
case 'enrich-hf-llm-bulk':
this.enrichBulkWithAgent();
break;
case 'delete-all':
@@ -377,7 +377,6 @@ export class BulkContextMenu extends BaseContextMenu {
const { agentManager } = await import('../../managers/AgentManager.js');
// Check if LLM is configured
const configured = await agentManager.isLlmConfigured();
if (!configured) {
showToast('toast.agent.llmNotConfigured', {}, 'warning');
@@ -386,41 +385,66 @@ export class BulkContextMenu extends BaseContextMenu {
const modelPaths = [...state.selectedModels];
// Connect WebSocket for progress
agentManager.connect();
// Set up one-time completion handler
const progressUI = state.loadingManager.showEnhancedProgress(
`Enriching metadata for ${modelPaths.length} models...`
);
function cleanupCallbacks() {
const pIdx = agentManager.progressCallbacks.indexOf(onProgress);
if (pIdx >= 0) agentManager.progressCallbacks.splice(pIdx, 1);
const cIdx = agentManager.completeCallbacks.indexOf(onComplete);
if (cIdx >= 0) agentManager.completeCallbacks.splice(cIdx, 1);
const eIdx = agentManager.errorCallbacks.indexOf(onError);
if (eIdx >= 0) agentManager.errorCallbacks.splice(eIdx, 1);
}
const onProgress = (data) => {
if (data.status === 'processing' && data.current_path && data.updated_data && Object.keys(data.updated_data).length > 0) {
if (state.virtualScroller?.updateSingleItem) {
state.virtualScroller.updateSingleItem(data.current_path, data.updated_data);
}
const pct = data.total > 0 ? Math.floor((data.processed / data.total) * 100) : 0;
const name = data.current_path.split('/').pop();
progressUI.updateProgress(pct, name, `Processing ${data.processed}/${data.total}: ${name}`);
}
};
agentManager.onProgress(onProgress);
const onComplete = (data) => {
const idx = agentManager.completeCallbacks.indexOf(onComplete);
if (idx >= 0) agentManager.completeCallbacks.splice(idx, 1);
cleanupCallbacks();
if (data.status === 'completed') {
if (state.bulkMode) bulkManager.toggleBulkMode();
progressUI.complete(data.summary || 'Enrich complete');
showToast(
'toast.agent.enrichComplete',
{ summary: data.summary || 'Done' },
'success'
);
// Soft reload to reflect updated metadata
window.location.reload();
} else if (data.status === 'error') {
showToast(
'toast.agent.enrichFailed',
{ error: data.error || 'Unknown error' },
'error'
);
}
};
agentManager.onComplete(onComplete);
showToast(
'toast.agent.enrichStarted',
{ count: modelPaths.length },
'info'
);
const onError = (data) => {
cleanupCallbacks();
if (state.bulkMode) bulkManager.toggleBulkMode();
state.loadingManager.hide();
showToast(
'toast.agent.enrichFailed',
{ error: data.error || 'Unknown error' },
'error'
);
};
agentManager.onError(onError);
try {
await agentManager.executeSkill('enrich_hf_metadata', modelPaths);
} catch (error) {
cleanupCallbacks();
if (state.bulkMode) bulkManager.toggleBulkMode();
state.loadingManager.hide();
showToast(
'toast.agent.enrichFailed',
{ error: error.message },
@@ -1,5 +1,6 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { state } from '../../state/index.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax, showToast } from '../../utils/uiHelpers.js';
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
@@ -23,6 +24,17 @@ export class LoraContextMenu extends BaseContextMenu {
showMenu(x, y, card) {
super.showMenu(x, y, card);
this.updateExcludeMenuItem();
this.updateEnrichMenuItem(card);
}
updateEnrichMenuItem(card) {
const enrichItem = this.menu?.querySelector('[data-action="enrich-hf-llm"]');
if (!enrichItem) return;
const hasHfUrl = !!card.dataset.hf_url;
enrichItem.classList.toggle('disabled', !hasHfUrl);
enrichItem.title = hasHfUrl
? ''
: 'Link this model to a HuggingFace repo first (Link Model \u2192 Link to HuggingFace)';
}
handleMenuAction(action, menuItem) {
@@ -63,7 +75,7 @@ export class LoraContextMenu extends BaseContextMenu {
case 'refresh-metadata':
getModelApiClient().refreshSingleModelMetadata(this.currentCard.dataset.filepath);
break;
case 'enrich-hf-agent':
case 'enrich-hf-llm':
this.enrichWithAgent(this.currentCard.dataset.filepath);
break;
case 'exclude':
@@ -78,39 +90,61 @@ export class LoraContextMenu extends BaseContextMenu {
async enrichWithAgent(filePath) {
const { agentManager } = await import('../../managers/AgentManager.js');
// Check if LLM is configured
const configured = await agentManager.isLlmConfigured();
if (!configured) {
showToast('toast.agent.llmNotConfigured', {}, 'warning');
return;
}
// Connect WebSocket for progress
agentManager.connect();
// Set up one-time completion handler
const progressUI = state.loadingManager.showEnhancedProgress(
'Enriching metadata with AI...'
);
function cleanupCallbacks() {
const pIdx = agentManager.progressCallbacks.indexOf(onProgress);
if (pIdx >= 0) agentManager.progressCallbacks.splice(pIdx, 1);
const cIdx = agentManager.completeCallbacks.indexOf(onComplete);
if (cIdx >= 0) agentManager.completeCallbacks.splice(cIdx, 1);
const eIdx = agentManager.errorCallbacks.indexOf(onError);
if (eIdx >= 0) agentManager.errorCallbacks.splice(eIdx, 1);
}
const onProgress = (data) => {
if (data.status === 'processing' && data.current_path && data.updated_data && Object.keys(data.updated_data).length > 0) {
if (state.virtualScroller?.updateSingleItem) {
state.virtualScroller.updateSingleItem(data.current_path, data.updated_data);
}
const pct = data.total > 0 ? Math.floor((data.processed / data.total) * 100) : 0;
const name = data.current_path.split('/').pop();
progressUI.updateProgress(pct, name, `Processing ${name}`);
}
};
agentManager.onProgress(onProgress);
const onComplete = (data) => {
const idx = agentManager.completeCallbacks.indexOf(onComplete);
if (idx >= 0) agentManager.completeCallbacks.splice(idx, 1);
cleanupCallbacks();
if (data.status === 'completed') {
progressUI.complete(data.summary || 'Enrich complete');
showToast('toast.agent.enrichComplete', { summary: data.summary || 'Done' }, 'success');
// Soft reload to reflect updated metadata
if (typeof resetAndReload === 'function') {
resetAndReload();
}
} else if (data.status === 'error') {
showToast('toast.agent.enrichFailed', { error: data.error || 'Unknown error' }, 'error');
}
};
agentManager.onComplete(onComplete);
// Show progress toast
showToast('toast.agent.enrichStarted', {}, 'info');
const onError = (data) => {
cleanupCallbacks();
state.loadingManager.hide();
showToast('toast.agent.enrichFailed', { error: data.error || 'Unknown error' }, 'error');
};
agentManager.onError(onError);
try {
await agentManager.executeSkill('enrich_hf_metadata', [filePath]);
} catch (error) {
cleanupCallbacks();
state.loadingManager.hide();
showToast('toast.agent.enrichFailed', { error: error.message }, 'error');
}
}
@@ -118,7 +152,9 @@ export class LoraContextMenu extends BaseContextMenu {
sendLoraToWorkflow(replaceMode) {
const card = this.currentCard;
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const loraSyntax = buildLoraSyntax(card.dataset.file_name, usageTips);
const folder = card.dataset.folder || '';
const loraName = folder ? `${folder}/${card.dataset.file_name}` : card.dataset.file_name;
const loraSyntax = buildLoraSyntax(loraName, usageTips);
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
}
@@ -187,6 +187,74 @@ export const ModelContextMenuMixin = {
setTimeout(() => urlInput.focus(), 50);
},
// HuggingFace linking methods
showLinkHfModal() {
const filePath = this.currentCard.dataset.filepath;
if (!filePath) return;
const confirmBtn = document.getElementById('confirmLinkHfBtn');
const urlInput = document.getElementById('hfModelUrl');
const errorDiv = document.getElementById('hfModelUrlError');
if (this._boundLinkHfHandler) {
confirmBtn.removeEventListener('click', this._boundLinkHfHandler);
}
this._boundLinkHfHandler = async () => {
const hfUrl = urlInput.value.trim();
if (!hfUrl) {
errorDiv.textContent = 'Please enter a HuggingFace repository URL.';
return;
}
const hfPattern = /^https?:\/\/huggingface\.co\/([^/]+\/[^/]+)\/?$/;
if (!hfPattern.test(hfUrl)) {
errorDiv.textContent = 'Invalid URL format. Expected: https://huggingface.co/user/repo';
return;
}
errorDiv.textContent = '';
modalManager.closeModal('linkHfModal');
try {
state.loadingManager.showSimpleLoading('Linking to HuggingFace...');
const response = await fetch('/api/lm/set-hf-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath, hf_url: hfUrl }),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.error || `Request failed: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
showToast('toast.contextMenu.linkHfSuccess', {}, 'success');
await this.resetAndReload();
} else {
throw new Error(data.error || 'Failed to link model');
}
} catch (error) {
console.error('Error linking model to HuggingFace:', error);
showToast('toast.contextMenu.linkHfFailed', { message: error.message }, 'error');
} finally {
state.loadingManager.hide();
}
};
confirmBtn.addEventListener('click', this._boundLinkHfHandler);
urlInput.value = '';
errorDiv.textContent = '';
modalManager.showModal('linkHfModal');
setTimeout(() => urlInput.focus(), 50);
},
extractModelVersionId(url) {
return extractCivitaiModelUrlParts(url);
},
@@ -295,6 +363,9 @@ export const ModelContextMenuMixin = {
case 'relink-civitai':
this.showRelinkCivitaiModal();
return true;
case 'link-hf':
this.showLinkHfModal();
return true;
case 'set-nsfw':
this.showNSFWLevelSelector(null, null, this.currentCard);
return true;
+1 -1
View File
@@ -358,7 +358,7 @@ class RecipeCard {
<div class="delete-preview">
${isVideo ?
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
`<img src="${previewUrl}" alt="${this.recipe.title}">`
`<img src="${previewUrl}" alt="${this.recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
}
</div>
<div class="delete-info">
+2 -2
View File
@@ -757,7 +757,7 @@ class RecipeModal {
`<video class="thumbnail-video" autoplay loop muted playsinline>
<source src="${lora.preview_url}" type="video/mp4">
</video>` :
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`;
let loraItemClass = 'recipe-lora-item';
if (existsLocally) {
@@ -1606,7 +1606,7 @@ class RecipeModal {
<video class="thumbnail-video" autoplay loop muted playsinline>
<source src="${previewUrl}" type="video/mp4">
</video>
` : `<img src="${previewUrl}" alt="Checkpoint preview">`;
` : `<img src="${previewUrl}" alt="Checkpoint preview" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`;
const badge = existsLocally ? `
<div class="local-badge">
+1 -1
View File
@@ -643,7 +643,7 @@ export function createModelCard(model, modelType) {
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ?
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
`<img src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
}
<div class="card-header">
${shouldBlur ?
@@ -432,7 +432,7 @@ function renderMediaMarkup(version) {
return `
<div class="version-media">
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(version.name || 'preview')}">
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(version.name || 'preview')}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">
</div>
`;
}
@@ -586,6 +586,7 @@ export function initMediaControlHandlers(container) {
const imageMetaRaw = this.dataset.imageMeta;
const imageUrl = this.dataset.imageUrl;
const imageNsfw = this.dataset.imageNsfw;
const imgId = this.dataset.imgId || '';
const localPath = this.dataset.localPath || '';
const showcaseSection = this.closest('.showcase-section');
const modelHash = showcaseSection ? showcaseSection.dataset.modelHash : '';
@@ -613,6 +614,7 @@ export function initMediaControlHandlers(container) {
meta: imageMeta,
url: imageUrl,
nsfwLevel: imageNsfw ? parseInt(imageNsfw, 10) : undefined,
id: imgId || undefined,
},
model_hash: modelHash,
model_name: modelName || modelHash,
@@ -174,7 +174,10 @@ function renderMediaItem(img, index, exampleFiles) {
const localUrl = localFile ? localFile.path : '';
// Calculate appropriate aspect ratio
const aspectRatio = (img.height / img.width) * 100;
// Defensive fallback: 0 width/height → 4:3 default (prevents NaN layout)
const safeW = img.width || 4;
const safeH = img.height || 3;
const aspectRatio = (safeH / safeW) * 100;
const containerWidth = 800; // modal content maximum width
const minHeightPercent = 40;
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
@@ -210,8 +213,8 @@ function renderMediaItem(img, index, exampleFiles) {
const model = meta.Model || '';
const steps = meta.steps || '';
const sampler = meta.sampler || '';
const cfgScale = meta.cfgScale || '';
const clipSkip = meta.clipSkip || '';
const cfgScale = meta.cfg_scale || meta.cfgScale || '';
const clipSkip = meta.clip_skip || meta.clipSkip || '';
// Check if we have any meaningful generation parameters
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
@@ -242,6 +245,7 @@ function renderMediaItem(img, index, exampleFiles) {
data-image-url="${img.url || ''}"
data-image-nsfw="${img.nsfwLevel ?? ''}"
data-image-id="${cdnImageId}"
data-img-id="${img.id || ''}"
data-local-path="${localFile ? localFile.path : ''}">
<i class="fas fa-book-open"></i>
</button>
+1
View File
@@ -15,6 +15,7 @@ import { initTheme, initBackToTop } from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { i18n } from './i18n/index.js';
import { onboardingManager } from './managers/OnboardingManager.js';
import './components/Combobox.js';
import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js';
import { createPageContextMenu, createGlobalContextMenu } from './components/ContextMenu/index.js';
import { initializeEventManagement } from './utils/eventManagementInit.js';
+15 -2
View File
@@ -165,6 +165,18 @@ class AgentManager {
*
* @returns {Promise<boolean>}
*/
_readProviderRequiresKey(providerId) {
const script = document.getElementById('llmProviderPresets');
if (!script) return true; // safe default
try {
const presets = JSON.parse(script.textContent);
const preset = presets[providerId];
return preset ? preset.requires_key !== false : true;
} catch {
return true;
}
}
async isLlmConfigured() {
try {
const response = await fetch('/api/lm/settings');
@@ -172,8 +184,9 @@ class AgentManager {
const data = await response.json();
const provider = data.settings?.llm_provider;
const hasModel = !!data.settings?.llm_model;
const hasKey = !!data.settings?.llm_api_key;
return hasModel && (hasKey || provider === 'ollama');
const hasKey = !!(data.settings?.llm_api_key_set || data.settings?.llm_api_key);
const needsKey = this._readProviderRequiresKey(provider);
return hasModel && (hasKey || !needsKey);
} catch {
return false;
}
+4 -1
View File
@@ -1,5 +1,5 @@
import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { showToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { WS_ENDPOINTS } from '../api/apiConfig.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
@@ -43,6 +43,9 @@ export class BatchImportManager {
setStorageItem('batch_import_skip_no_metadata', e.target.checked);
});
}
// Auto-append newline after pasting a URL in the batch URL input
setupAutoNewlineOnPaste('batchUrlInput');
}
/**
+35 -8
View File
@@ -397,6 +397,7 @@ export class BulkManager {
const updated = {
...existing,
fileName: card.dataset.file_name ?? existing.fileName,
folder: card.dataset.folder ?? existing.folder,
usageTips: card.dataset.usage_tips ?? existing.usageTips,
modelName: card.dataset.name ?? existing.modelName,
};
@@ -494,7 +495,8 @@ export class BulkManager {
if (metadata) {
const usageTips = JSON.parse(metadata.usageTips || '{}');
loraSyntaxes.push(buildLoraSyntax(metadata.fileName, usageTips));
const loraName = metadata.folder ? `${metadata.folder}/${metadata.fileName}` : metadata.fileName;
loraSyntaxes.push(buildLoraSyntax(loraName, usageTips));
} else {
missingLoras.push(filepath);
}
@@ -537,7 +539,8 @@ export class BulkManager {
if (metadata) {
const usageTips = JSON.parse(metadata.usageTips || '{}');
loraSyntaxes.push(buildLoraSyntax(metadata.fileName, usageTips));
const loraName = metadata.folder ? `${metadata.folder}/${metadata.fileName}` : metadata.fileName;
loraSyntaxes.push(buildLoraSyntax(loraName, usageTips));
} else {
missingLoras.push(filepath);
}
@@ -553,7 +556,8 @@ export class BulkManager {
return;
}
await sendLoraToWorkflow(loraSyntaxes.join(', '), replaceMode, 'lora');
const exitBulkMode = () => { if (state.bulkMode) this.toggleBulkMode(); };
await sendLoraToWorkflow(loraSyntaxes.join(', '), replaceMode, 'lora', exitBulkMode);
}
async _sendAllEmbeddingsToWorkflow() {
@@ -575,7 +579,8 @@ export class BulkManager {
}
const joinedCode = embeddingCodes.join(', ');
await sendEmbeddingToWorkflow(joinedCode);
const exitBulkMode = () => { if (state.bulkMode) this.toggleBulkMode(); };
await sendEmbeddingToWorkflow(joinedCode, exitBulkMode);
}
showBulkDeleteModal() {
@@ -633,7 +638,7 @@ export class BulkManager {
filePaths.forEach(path => {
state.virtualScroller.removeItemByFilePath(path);
});
this.clearSelection();
if (state.bulkMode) this.toggleBulkMode();
if (window.modelDuplicatesManager) {
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
@@ -674,6 +679,7 @@ export class BulkManager {
const modelId = this.parseModelId(item?.civitai?.modelId);
metadataCache.set(item.file_path, {
fileName: item.file_name,
folder: item.folder || '',
usageTips: item.usage_tips || '{}',
modelName: item.name || item.file_name,
...(modelId !== null ? { modelId } : {})
@@ -763,8 +769,9 @@ export class BulkManager {
`Re-import complete: ${completed} re-imported, ${failed} failed`
);
const { resetAndReload: recipeResetAndReload } = await import('../api/recipeApi.js');
recipeResetAndReload(false, { preserveScroll: false });
this.clearSelection();
if (state.bulkMode) this.toggleBulkMode();
recipeResetAndReload(false, { preserveScroll: false });
} else {
state.loadingManager.hide();
showToast('toast.recipes.reimportBulkFailed', {}, 'error');
@@ -829,7 +836,7 @@ export class BulkManager {
);
}
this.clearSelection();
if (state.bulkMode) this.toggleBulkMode();
} else {
throw new Error(result.error || 'Bulk repair failed');
}
@@ -874,6 +881,8 @@ export class BulkManager {
if (this.isStripVisible) {
this.updateThumbnailStrip();
}
if (state.bulkMode) this.toggleBulkMode();
}
} catch (error) {
@@ -927,6 +936,7 @@ export class BulkManager {
showToast('toast.models.bulkUpdatesNone', { type: typeLabel }, 'info');
}
if (state.bulkMode) this.toggleBulkMode();
await resetAndReload(false);
} catch (error) {
console.error('Error checking updates for selected models:', error);
@@ -1273,6 +1283,8 @@ export class BulkManager {
showToast(toastKey, { count: failCount }, 'warning');
}
if (state.bulkMode) this.toggleBulkMode();
} catch (error) {
console.error('Error during bulk tag operation:', error);
const toastKey = mode === 'replace' ? 'toast.models.bulkTagsReplaceFailed' : 'toast.models.bulkTagsAddFailed';
@@ -1398,6 +1410,8 @@ export class BulkManager {
} else {
showToast('toast.models.bulkFavoriteFailed', {}, 'error');
}
if (state.bulkMode) this.toggleBulkMode();
}
/**
@@ -1526,6 +1540,8 @@ export class BulkManager {
showToast('toast.models.bulkContentRatingFailed', {}, 'error');
}
if (state.bulkMode) this.toggleBulkMode();
return successCount > 0;
}
@@ -1580,6 +1596,8 @@ export class BulkManager {
} else {
showToast('toast.models.skipMetadataRefreshFailed', {}, 'error');
}
if (state.bulkMode) this.toggleBulkMode();
}
/**
@@ -1647,13 +1665,19 @@ export class BulkManager {
cancelled = true;
});
const isRecipesPage = state.currentPageType === 'recipes';
for (const filepath of state.selectedModels) {
if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
break;
}
try {
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel });
if (isRecipesPage) {
await updateRecipeMetadata(filepath, { base_model: newBaseModel });
} else {
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel });
}
successCount++;
} catch (error) {
errorCount++;
@@ -1674,6 +1698,8 @@ export class BulkManager {
showToast('toast.models.bulkBaseModelUpdateFailed', {}, 'error');
}
if (state.bulkMode) this.toggleBulkMode();
} catch (error) {
console.error('Error during bulk base model operation:', error);
showToast('toast.models.bulkBaseModelUpdateFailed', {}, 'error');
@@ -1711,6 +1737,7 @@ export class BulkManager {
// Call the auto-organize method with selected file paths
await apiClient.autoOrganizeModels(filePaths);
if (state.bulkMode) this.toggleBulkMode();
resetAndReload(true);
} catch (error) {
console.error('Error during bulk auto-organize:', error);
@@ -196,6 +196,17 @@ export class BulkMissingLoraDownloadManager {
let completedDownloads = 0;
let failedDownloads = 0;
let currentLoraProgress = 0;
let cancelled = false;
loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
try {
await this.loraApiClient.cancelDownload(batchDownloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
});
// Set up WebSocket message handler
ws.onmessage = (event) => {
@@ -207,6 +218,11 @@ export class BulkMissingLoraDownloadManager {
return;
}
if (data.status === 'cancelled') {
cancelled = true;
return;
}
// Process progress updates
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
currentLoraProgress = data.progress;
@@ -249,6 +265,8 @@ export class BulkMissingLoraDownloadManager {
// Download each LoRA sequentially
for (let i = 0; i < lorasToDownload.length; i++) {
if (cancelled) break;
const lora = lorasToDownload[i];
currentLoraProgress = 0;
@@ -275,11 +293,13 @@ export class BulkMissingLoraDownloadManager {
modelId,
versionId,
loraRoot,
'', // Empty relative path, use default paths
'',
useDefaultPaths,
batchDownloadId
);
if (cancelled) break;
if (!response.success) {
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
failedDownloads++;
@@ -288,8 +308,10 @@ export class BulkMissingLoraDownloadManager {
updateProgress(100, completedDownloads, '');
}
} catch (error) {
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
failedDownloads++;
if (!cancelled) {
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
failedDownloads++;
}
}
}
@@ -300,7 +322,10 @@ export class BulkMissingLoraDownloadManager {
loadingManager.hide();
// Show completion message
if (failedDownloads === 0) {
if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
showToast('toast.loras.downloadPartialSuccess', {
+290 -97
View File
@@ -1,5 +1,5 @@
import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { showToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { LoadingManager } from './LoadingManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
@@ -31,6 +31,7 @@ export class DownloadManager {
// HF download state
this.hfRepoId = null;
this.hfSelectedFiles = [];
this.hfRepoCollapsed = {};
this.loadingManager = new LoadingManager();
this.folderTreeManager = new FolderTreeManager();
@@ -107,7 +108,8 @@ export class DownloadManager {
// Default path toggle handler
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
// Auto-append newline after pasting a URL so users can paste multiple URLs in succession
setupAutoNewlineOnPaste('modelUrl');
}
updateModalLabels() {
@@ -173,6 +175,7 @@ export class DownloadManager {
// Reset HF state
this.hfRepoId = null;
this.hfSelectedFiles = [];
this.hfRepoCollapsed = {};
}
async retrieveVersionsForModel(modelId, source = null) {
@@ -463,8 +466,8 @@ export class DownloadManager {
const trimmed = url.trim();
if (!trimmed) return null;
// CivitAI
if (/civitai\.com\/models\//i.test(trimmed) || /civitaiarchive|civarchive/i.test(trimmed)) {
// CivitAI — matches civitai.com, civitai.red, civitai.green, etc.
if (/civitai\.(?:com|red|green)\/models\//i.test(trimmed) || /civitaiarchive|civarchive/i.test(trimmed)) {
// Will be parsed by existing CivitAI logic
return { type: 'civitai' };
}
@@ -725,14 +728,23 @@ export class DownloadManager {
confirmFileSelection() {
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
if (!selectedRadio) return;
if (!selectedRadio) {
console.warn('[download] confirmFileSelection: no radio button checked');
return;
}
const version = this.currentVersion;
if (!version) return;
if (!version) {
console.warn('[download] confirmFileSelection: no currentVersion set');
return;
}
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o',
this.selectedFile?.id, this.selectedFile?.name, this.selectedFile?.type, this.selectedFile?.metadata);
document.getElementById('fileSelectionStep').style.display = 'none';
document.getElementById('locationStep').style.display = 'block';
this.proceedToLocationContent();
@@ -869,16 +881,26 @@ export class DownloadManager {
const displayName = versionName || `#${versionId}`;
let ws = null;
let updateProgress = () => { };
let cancelled = false;
const downloadId = Date.now().toString();
try {
this.loadingManager.restoreProgressBar();
updateProgress = this.loadingManager.showDownloadProgress(1);
updateProgress(0, 0, displayName);
const downloadId = Date.now().toString();
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
this.loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
try {
await this.apiClient.cancelDownload(downloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
});
ws.onmessage = event => {
const data = JSON.parse(event.data);
@@ -887,6 +909,12 @@ export class DownloadManager {
return;
}
if (data.status === 'cancelled') {
cancelled = true;
this.loadingManager.setStatus(translate('modals.download.status.cancelled', {}, 'Download cancelled'));
return;
}
if (data.status === 'progress' && data.download_id === downloadId) {
const metrics = {
bytesDownloaded: data.bytes_downloaded,
@@ -925,6 +953,10 @@ export class DownloadManager {
fileParams
);
if (cancelled) {
return false;
}
if (response?.skipped) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
updateProgress(100, 0, displayName);
@@ -965,8 +997,12 @@ export class DownloadManager {
return true;
} catch (error) {
console.error('Failed to download model version:', error);
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
if (cancelled) {
console.log('Download cancelled by user:', downloadId);
} else {
console.error('Failed to download model version:', error);
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
}
return false;
} finally {
try {
@@ -986,16 +1022,33 @@ export class DownloadManager {
const totalFiles = this.hfSelectedFiles.length;
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
let cancelled = false;
let currentDownloadId = null;
this.loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
if (currentDownloadId) {
try {
await this.apiClient.cancelDownload(currentDownloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
}
});
try {
let completedDownloads = 0;
for (let i = 0; i < totalFiles; i++) {
if (cancelled) break;
const filename = this.hfSelectedFiles[i];
updateProgress(0, completedDownloads, filename);
this.loadingManager.setStatus(`Downloading ${filename}...`);
const downloadId = Date.now().toString() + '_' + i;
currentDownloadId = Date.now().toString() + '_' + i;
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${currentDownloadId}`);
try {
await new Promise((resolve, reject) => {
@@ -1003,12 +1056,13 @@ export class DownloadManager {
ws.onerror = reject;
});
// Capture completed count at WS creation time so progress
// updates arriving after completedDownloads increments still
// show the correct "N / total" position.
const snapshotCompleted = completedDownloads;
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'cancelled') {
cancelled = true;
return;
}
if (data.status === 'progress') {
const metrics = {
bytesDownloaded: data.bytes_downloaded,
@@ -1026,9 +1080,11 @@ export class DownloadManager {
modelRoot,
relativePath: targetFolder,
useDefaultPaths,
download_id: downloadId,
download_id: currentDownloadId,
});
if (cancelled) break;
if (response?.success) {
completedDownloads++;
updateProgress(100, completedDownloads, filename);
@@ -1038,13 +1094,19 @@ export class DownloadManager {
}
}
showToast('toast.loras.downloadCompleted', {}, 'success');
// Reload page data — model is already in scanner cache via backend
if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else {
showToast('toast.loras.downloadCompleted', {}, 'success');
}
await resetAndReload(true);
return true;
} catch (error) {
console.error('Failed to download HF model:', error);
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
if (!cancelled) {
console.error('Failed to download HF model:', error);
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
}
return false;
} finally {
this.loadingManager.hide();
@@ -1077,7 +1139,7 @@ export class DownloadManager {
showBatchPreviewStep() {
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
document.getElementById('batchPreviewStep').style.display = 'block';
document.getElementById('batchPreviewStep').style.display = 'flex';
const validCount = this.batchModels.filter(m => {
if (m.error) return false;
@@ -1091,56 +1153,36 @@ export class DownloadManager {
const list = document.getElementById('batchPreviewList');
const hasHfItems = this.batchModels.some(m => m.source === 'huggingface' && !m.error);
let itemsHtml = this.batchModels.map((item, index) => {
if (item.error) {
return `
<div class="batch-preview-item batch-preview-error" data-index="${index}">
<div class="batch-preview-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="batch-preview-info">
<div class="batch-preview-name">${item.url}</div>
<div class="batch-preview-meta batch-preview-error-text">${item.error}</div>
</div>
<button class="batch-preview-remove" data-index="${index}" title="${translate('common.actions.remove', {}, 'Remove')}">
<i class="fas fa-times"></i>
</button>
// Error items render flat, outside any group
const errorItemsHtml = this.batchModels.map((item, index) => {
if (!item.error) return null;
return `
<div class="batch-preview-item batch-preview-error" data-index="${index}">
<div class="batch-preview-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
`;
}
<div class="batch-preview-info">
<div class="batch-preview-name">${item.url}</div>
<div class="batch-preview-meta batch-preview-error-text">${item.error}</div>
</div>
<button class="batch-preview-remove" data-index="${index}" title="${translate('common.actions.remove', {}, 'Remove')}">
<i class="fas fa-times"></i>
</button>
</div>
`;
}).filter(Boolean).join('');
// CivitAI items render flat, outside any group (unchanged)
const civitaiItemsHtml = this.batchModels.map((item, index) => {
if (item.error) return null;
if (item.source === 'huggingface') return null;
const ver = item.selectedVersion;
// HF batch item rendering with checkbox
if (item.source === 'huggingface') {
const hfSize = item.fileSizeBytes
? formatFileSize(item.fileSizeBytes)
: '?';
return `
<div class="batch-preview-item" data-index="${index}">
<input type="checkbox" class="batch-preview-checkbox"
data-index="${index}" ${item.checked !== false ? 'checked' : ''} />
<div class="batch-preview-info">
<div class="batch-preview-name">${item.displayName || item.filename || `HF #${index}`} <span class="hf-badge">HF</span></div>
<div class="batch-preview-meta">
<span>${hfSize}</span>
<span>${item.repo || ''}</span>
</div>
</div>
<button class="batch-preview-remove" data-index="${index}" title="${translate('common.actions.remove', {}, 'Remove')}">
<i class="fas fa-times"></i>
</button>
</div>
`;
}
const firstImage = ver?.images?.find(img => !img.url.endsWith('.mp4'));
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
const fileSize = ver?.modelSizeKB
? (ver.modelSizeKB / 1024).toFixed(1)
: (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?');
const existsLocally = ver?.existsLocally;
return `
<div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}">
<div class="batch-preview-thumbnail">
@@ -1161,8 +1203,59 @@ export class DownloadManager {
` : ''}
</div>
`;
}).filter(Boolean).join('');
// Group HF items by repo (data model stays flat — only rendering groups)
const hfGroups = {};
this.batchModels.forEach((item, index) => {
if (item.error || item.source !== 'huggingface') return;
const repo = item.repo || 'unknown';
if (!hfGroups[repo]) hfGroups[repo] = [];
hfGroups[repo].push({ item, index });
});
const renderHfItem = ({ item, index }) => {
const hfSize = item.fileSizeBytes ? formatFileSize(item.fileSizeBytes) : '?';
return `
<div class="batch-preview-item" data-index="${index}">
<input type="checkbox" class="batch-preview-checkbox"
data-index="${index}" ${item.checked !== false ? 'checked' : ''} />
<div class="batch-preview-info">
<div class="batch-preview-name">${item.displayName || item.filename || `HF #${index}`} <span class="hf-badge">HF</span></div>
<div class="batch-preview-meta">
<span>${hfSize}</span>
<span>${item.repo || ''}</span>
</div>
</div>
<button class="batch-preview-remove" data-index="${index}" title="${translate('common.actions.remove', {}, 'Remove')}">
<i class="fas fa-times"></i>
</button>
</div>
`;
};
const hfGroupsHtml = Object.keys(hfGroups).map(repo => {
const items = hfGroups[repo];
const isCollapsed = this.hfRepoCollapsed[repo] === true;
const allChecked = items.every(({ item }) => item.checked !== false);
const fileCount = items.length;
return `
<div class="batch-preview-group" data-repo="${repo}">
<div class="batch-preview-group-header">
<i class="fas fa-chevron-right batch-preview-group-toggle ${isCollapsed ? '' : 'expanded'}"></i>
<span class="batch-preview-group-name">${repo}</span>
<span class="batch-preview-group-count">${fileCount} ${translate('modals.download.fileSelection.files', {}, 'files')}</span>
<input type="checkbox" class="batch-preview-group-select-all" data-repo="${repo}" ${allChecked ? 'checked' : ''} />
</div>
<div class="batch-preview-group-body ${isCollapsed ? '' : 'expanded'}">
${items.map(renderHfItem).join('')}
</div>
</div>
`;
}).join('');
let itemsHtml = errorItemsHtml + civitaiItemsHtml + hfGroupsHtml;
// Prepend select-all toolbar if there are HF items with checkboxes
if (hasHfItems) {
const allChecked = this.batchModels
@@ -1178,7 +1271,90 @@ export class DownloadManager {
list.innerHTML = itemsHtml;
const updateCountAndSelectAll = () => {
const checkedCount = this.batchModels.filter(
m => !m.error && m.checked !== false
).length;
document.getElementById('downloadModalTitle').textContent =
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
` (${checkedCount})`;
const nextBtn = document.getElementById('nextFromBatchBtn');
nextBtn.disabled = checkedCount === 0;
nextBtn.classList.toggle('disabled', checkedCount === 0);
// Global select-all
const selectAll = document.getElementById('batchSelectAll');
if (selectAll) {
const hfItems = this.batchModels.filter(m => m.source === 'huggingface' && !m.error);
selectAll.checked = hfItems.length > 0 && hfItems.every(m => m.checked !== false);
}
// Per-group select-all
list.querySelectorAll('.batch-preview-group-select-all').forEach(gsa => {
const repo = gsa.dataset.repo;
const repoItems = this.batchModels.filter(m => m.source === 'huggingface' && !m.error && m.repo === repo);
gsa.checked = repoItems.length > 0 && repoItems.every(m => m.checked !== false);
});
};
list.onclick = (e) => {
// Per-group select-all checkbox
const groupSelectAll = e.target.closest('.batch-preview-group-select-all');
if (groupSelectAll) {
const repo = groupSelectAll.dataset.repo;
const checked = groupSelectAll.checked;
this.batchModels.forEach((m, idx) => {
if (m.source === 'huggingface' && !m.error && m.repo === repo) {
m.checked = checked;
const cb = list.querySelector(`.batch-preview-checkbox[data-index="${idx}"]`);
if (cb) cb.checked = checked;
}
});
updateCountAndSelectAll();
return;
}
const header = e.target.closest('.batch-preview-group-header');
if (header) {
const group = header.closest('.batch-preview-group');
const repo = group.dataset.repo;
const body = group.querySelector('.batch-preview-group-body');
const toggle = group.querySelector('.batch-preview-group-toggle');
const isCollapsed = this.hfRepoCollapsed[repo];
if (isCollapsed) {
this.hfRepoCollapsed[repo] = false;
body.style.transition = ''; // restore in case collapse was interrupted
body.classList.add('expanded');
toggle.classList.add('expanded');
// force reflow so expanded class is registered before setting height
void body.offsetHeight;
body.style.maxHeight = body.scrollHeight + 'px';
const onEnd = (e) => {
if (e.propertyName !== 'max-height') return;
if (this.hfRepoCollapsed[repo] !== false) return;
body.style.maxHeight = ''; // fall back to .expanded's 9999px
body.removeEventListener('transitionend', onEnd);
};
body.addEventListener('transitionend', onEnd);
} else {
this.hfRepoCollapsed[repo] = true;
body.style.maxHeight = body.scrollHeight + 'px';
requestAnimationFrame(() => {
// animate only max-height; keep expanded so opacity stays 1
body.style.transition = 'max-height 0.35s ease';
body.style.maxHeight = '0';
toggle.classList.remove('expanded');
const onEnd = (e) => {
if (e.propertyName !== 'max-height') return;
if (this.hfRepoCollapsed[repo] !== true) return; // state changed since
body.classList.remove('expanded');
body.style.transition = '';
body.removeEventListener('transitionend', onEnd);
};
body.addEventListener('transitionend', onEnd);
});
}
return;
}
const removeBtn = e.target.closest('.batch-preview-remove');
if (removeBtn) {
const idx = parseInt(removeBtn.dataset.index);
@@ -1193,7 +1369,7 @@ export class DownloadManager {
}
};
// Checkbox handler for HF batch items
// Individual HF checkbox handler
const checkboxes = list.querySelectorAll('.batch-preview-checkbox');
checkboxes.forEach(cb => {
cb.addEventListener('change', (e) => {
@@ -1201,26 +1377,11 @@ export class DownloadManager {
if (this.batchModels[idx]) {
this.batchModels[idx].checked = e.target.checked;
}
// Update valid count in title and Next button
const checkedCount = this.batchModels.filter(
m => !m.error && m.checked !== false
).length;
document.getElementById('downloadModalTitle').textContent =
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
` (${checkedCount})`;
const nextBtn = document.getElementById('nextFromBatchBtn');
nextBtn.disabled = checkedCount === 0;
nextBtn.classList.toggle('disabled', checkedCount === 0);
// Update select-all checkbox state
const selectAll = document.getElementById('batchSelectAll');
if (selectAll) {
const hfItems = this.batchModels.filter(m => m.source === 'huggingface' && !m.error);
selectAll.checked = hfItems.length > 0 && hfItems.every(m => m.checked !== false);
}
updateCountAndSelectAll();
});
});
// Select-all handler
// Global select-all handler
const selectAll = document.getElementById('batchSelectAll');
if (selectAll) {
selectAll.addEventListener('change', (e) => {
@@ -1233,16 +1394,7 @@ export class DownloadManager {
this.batchModels[idx].checked = checked;
}
});
// Update valid count in title and Next button
const checkedCount = this.batchModels.filter(
m => !m.error && m.checked !== false
).length;
document.getElementById('downloadModalTitle').textContent =
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
` (${checkedCount})`;
const nextBtn = document.getElementById('nextFromBatchBtn');
nextBtn.disabled = checkedCount === 0;
nextBtn.classList.toggle('disabled', checkedCount === 0);
updateCountAndSelectAll();
});
}
@@ -1333,12 +1485,23 @@ export class DownloadManager {
}
const fileParams = this.selectedFile ? {
id: this.selectedFile.id,
type: this.selectedFile.type || 'Model',
format: this.selectedFile.metadata?.format || 'SafeTensor',
size: this.selectedFile.metadata?.size || 'full',
fp: this.selectedFile.metadata?.fp,
format: this.selectedFile.metadata?.format || null,
size: this.selectedFile.metadata?.size || null,
fp: this.selectedFile.metadata?.fp || null,
} : null;
if (fileParams) {
console.log('[download] startDownload (single): fileParams built from selectedFile — id=%s, type=%s, format=%s, size=%s, fp=%s',
fileParams.id, fileParams.type, fileParams.format, fileParams.size, fileParams.fp);
} else {
console.log('[download] startDownload (single): this.selectedFile is null — no file selection, will download primary/default file. version=%s has %d files',
this.currentVersion?.id, (this.currentVersion?.files || []).length);
}
modalManager.closeModal('downloadModal');
return this.executeDownloadWithProgress({
modelId: this.modelId,
versionId: this.currentVersion.id,
@@ -1377,11 +1540,27 @@ export class DownloadManager {
let completedDownloads = 0;
let failedDownloads = 0;
let cancelled = false;
loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
try {
await this.apiClient.cancelDownload(batchDownloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
});
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'download_id') return;
if (data.status === 'cancelled') {
cancelled = true;
return;
}
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
const current = downloadItems[completedDownloads + failedDownloads];
const name = current?.selectedVersion?.name || current?.displayName || current?.filename || `#${completedDownloads + failedDownloads + 1}`;
@@ -1400,6 +1579,8 @@ export class DownloadManager {
});
for (let i = 0; i < downloadItems.length; i++) {
if (cancelled) break;
const item = downloadItems[i];
const name = item.displayName || item.filename || (item.selectedVersion?.name || `Model #${item.modelId}`);
const isHf = item.source === 'huggingface';
@@ -1410,7 +1591,6 @@ export class DownloadManager {
try {
let response;
if (isHf) {
// Per-file WebSocket for real-time progress
const downloadId = Date.now().toString() + '_hf_' + i;
const wsHf = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
try {
@@ -1444,6 +1624,8 @@ export class DownloadManager {
wsHf.close();
}
} else {
console.log('[download] batch download: fileParams NOT passed for modelId=%s, versionId=%s — backend will use primary file',
item.modelId, item.selectedVersion?.id);
response = await this.apiClient.downloadModel(
item.modelId,
item.selectedVersion.id,
@@ -1455,6 +1637,8 @@ export class DownloadManager {
);
}
if (cancelled) break;
if (!response.success) {
failedDownloads++;
} else {
@@ -1462,15 +1646,20 @@ export class DownloadManager {
updateProgress(100, completedDownloads, '');
}
} catch (err) {
console.error(`Failed to download ${name}:`, err);
failedDownloads++;
if (!cancelled) {
console.error(`Failed to download ${name}:`, err);
failedDownloads++;
}
}
}
ws.close();
loadingManager.hide();
if (failedDownloads === 0) {
if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
showToast('toast.loras.downloadPartialSuccess', {
@@ -1488,6 +1677,10 @@ export class DownloadManager {
modelRoot = '',
targetFolder = ''
} = {}) {
console.warn('[download] downloadVersionWithDefaults: NO fileParams will be sent — backend will always use primary file. '
+ 'modelType=%s, modelId=%s, versionId=%s, versionName="%s"',
modelType, modelId, versionId, versionName);
try {
this.apiClient = getModelApiClient(modelType);
} catch (error) {
+123 -24
View File
@@ -1,6 +1,7 @@
import { getCurrentPageState } from '../state/index.js';
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { getApiEndpoints } from '../api/apiConfig.js';
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js';
@@ -24,6 +25,12 @@ export class FilterManager {
this.baseModelOptions = [];
this.tagsLoaded = false;
// Tag search state
this.modelTagsSearchInput = document.getElementById('modelTagsSearchInput');
this.tagSearchDebounceTimer = null;
this.tagSearchAbortController = null;
this.tagSearchQuery = '';
// Initialize preset manager
this.presetManager = new FilterPresetManager({
page: this.currentPage,
@@ -123,6 +130,60 @@ export class FilterManager {
this.renderBaseModelTags();
});
}
if (this.modelTagsSearchInput) {
this.modelTagsSearchInput.addEventListener('input', () => {
clearTimeout(this.tagSearchDebounceTimer);
this.tagSearchDebounceTimer = setTimeout(() => {
this.handleTagSearchInput();
}, 150);
});
}
}
handleTagSearchInput() {
const query = (this.modelTagsSearchInput?.value || '').trim();
const trimmedQuery = query.toLowerCase();
if (trimmedQuery === this.tagSearchQuery) return;
this.tagSearchQuery = trimmedQuery;
if (!trimmedQuery) {
// Empty query: reload top tags (default/common view)
this.loadTopTags();
return;
}
this.searchTags(trimmedQuery);
}
async searchTags(query) {
// Abort any in-flight search request
if (this.tagSearchAbortController) {
this.tagSearchAbortController.abort();
}
this.tagSearchAbortController = new AbortController();
const controller = this.tagSearchAbortController;
try {
const tagsEndpoint = `${getApiEndpoints(this.currentPage).searchTags}?q=${encodeURIComponent(query)}&limit=20`;
const response = await fetch(tagsEndpoint, { signal: controller.signal });
if (!response.ok) throw new Error('Failed to search tags');
const data = await response.json();
if (controller.signal.aborted) return; // stale response
if (data.success && data.tags) {
this.createTagFilterElements(data.tags);
} else {
throw new Error('Invalid response format');
}
} catch (error) {
if (error.name === 'AbortError') return; // expected, ignore
console.error('Error searching tags:', error);
const tagsContainer = document.getElementById('modelTagsFilter');
if (tagsContainer) {
tagsContainer.innerHTML = '<div class="tags-error">Failed to search tags</div>';
}
const emptyState = document.getElementById('modelTagsEmptyState');
if (emptyState) emptyState.hidden = true;
}
}
getNormalizedSearchQuery(input) {
@@ -146,15 +207,24 @@ export class FilterManager {
}
async loadTopTags() {
// Abort any in-flight tag search request
if (this.tagSearchAbortController) {
this.tagSearchAbortController.abort();
this.tagSearchAbortController = null;
}
this.tagSearchQuery = '';
try {
// Show loading state
const tagsContainer = document.getElementById('modelTagsFilter');
const emptyState = document.getElementById('modelTagsEmptyState');
if (!tagsContainer) return;
if (emptyState) emptyState.hidden = true;
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
// Determine the API endpoint based on the page type
const tagsEndpoint = `/api/lm/${this.currentPage}/top-tags?limit=20`;
const tagsEndpoint = `${getApiEndpoints(this.currentPage).topTags}?limit=20`;
const response = await fetch(tagsEndpoint);
if (!response.ok) throw new Error('Failed to fetch tags');
@@ -179,29 +249,38 @@ export class FilterManager {
createTagFilterElements(tags) {
const tagsContainer = document.getElementById('modelTagsFilter');
const emptyState = document.getElementById('modelTagsEmptyState');
if (!tagsContainer) return;
tagsContainer.innerHTML = '';
if (emptyState) emptyState.hidden = true;
// Collect existing tag names from the API response
const existingTagNames = new Set(tags.map(t => t.tag));
// Add any active filter tags that aren't in the top 20
// Collect active filter tags that aren't in the response (excluding __no_tags__)
const missingSelectedTags = [];
if (this.filters.tags) {
Object.keys(this.filters.tags).forEach(tagName => {
// Skip special tags like __no_tags__
if (tagName.startsWith('__')) return;
if (!existingTagNames.has(tagName)) {
// Add this tag to the list with count 0 (unknown)
tags.push({ tag: tagName, count: 0 });
missingSelectedTags.push({ tag: tagName, count: 0 });
existingTagNames.add(tagName);
}
});
}
// Append missing selected tags after the API results so they appear inline
for (const t of missingSelectedTags) {
tags.push(t);
}
if (!tags.length) {
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
if (this.tagSearchQuery) {
if (emptyState) emptyState.hidden = false;
} else {
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
}
return;
}
@@ -209,6 +288,10 @@ export class FilterManager {
const tagEl = document.createElement('div');
tagEl.className = 'filter-tag tag-filter';
const tagName = tag.tag;
if (missingSelectedTags.some(t => t.tag === tagName)) {
tagEl.classList.add('extra-tag');
}
tagEl.dataset.tag = tagName;
// Show count only if it's > 0 (known count)
@@ -234,26 +317,28 @@ export class FilterManager {
tagsContainer.appendChild(tagEl);
});
// Add "No tags" as a special filter at the end
const noTagsEl = document.createElement('div');
noTagsEl.className = 'filter-tag tag-filter special-tag';
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
const noTagsKey = '__no_tags__';
noTagsEl.dataset.tag = noTagsKey;
noTagsEl.innerHTML = noTagsLabel;
// Add "No tags" as a special filter at the end (skip during search)
if (!this.tagSearchQuery) {
const noTagsEl = document.createElement('div');
noTagsEl.className = 'filter-tag tag-filter special-tag';
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
const noTagsKey = '__no_tags__';
noTagsEl.dataset.tag = noTagsKey;
noTagsEl.innerHTML = noTagsLabel;
noTagsEl.addEventListener('click', async () => {
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
const newState = this.getNextTriStateState(currentState);
this.setTagFilterState(noTagsKey, newState);
this.applyTagElementState(noTagsEl, newState);
noTagsEl.addEventListener('click', async () => {
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
const newState = this.getNextTriStateState(currentState);
this.setTagFilterState(noTagsKey, newState);
this.applyTagElementState(noTagsEl, newState);
this.updateActiveFiltersCount();
this.updateActiveFiltersCount();
await this.applyFilters(false);
});
await this.applyFilters(false);
});
tagsContainer.appendChild(noTagsEl);
tagsContainer.appendChild(noTagsEl);
}
this.updateTagSelections();
}
@@ -341,7 +426,7 @@ export class FilterManager {
if (!baseModelTagsContainer) return;
// Set the API endpoint based on current page
const apiEndpoint = `/api/lm/${this.currentPage}/base-models?limit=0`;
const apiEndpoint = `${getApiEndpoints(this.currentPage).baseModels}?limit=0`;
// Fetch base models
fetch(apiEndpoint)
@@ -721,6 +806,16 @@ export class FilterManager {
tagLogic: 'any'
});
// Clear tag search input and reset search state
if (this.modelTagsSearchInput) {
this.modelTagsSearchInput.value = '';
}
this.tagSearchQuery = '';
if (this.tagSearchAbortController) {
this.tagSearchAbortController.abort();
this.tagSearchAbortController = null;
}
// Update tag logic toggle UI
this.updateTagLogicToggleUI();
@@ -731,6 +826,10 @@ export class FilterManager {
// Update UI
this.updateTagSelections();
this.updateActiveFiltersCount();
// Reload tag area to drop any non-top-20 tags from the deactivated preset
if (this.tagsLoaded) {
await this.loadTopTags();
}
this.presetManager.renderPresets(); // Re-render to remove active state
// Remove from local Storage
+13 -19
View File
@@ -478,11 +478,9 @@ export class FilterPresetManager {
const pageState = getCurrentPageState();
pageState.filters = this.filterManager.cloneFilters();
// If tags haven't been loaded yet, load them first
if (!this.filterManager.tagsLoaded) {
await this.filterManager.loadTopTags();
this.filterManager.tagsLoaded = true;
}
// Refresh tag display so preset's non-top-20 tags appear inline
await this.filterManager.loadTopTags();
this.filterManager.tagsLoaded = true;
// Check again after async operation
if (requestId !== this.applyPresetRequestId) return;
@@ -745,8 +743,16 @@ export class FilterPresetManager {
presetEl.classList.add('active');
}
presetEl.addEventListener('click', (e) => {
e.stopPropagation();
// Apply preset on click (toggle if already active)
// Bind to the whole .filter-preset div so clicking anywhere inside triggers apply
presetEl.addEventListener('click', async () => {
this.cancelPendingDelete();
if (this.activePreset === preset.name) {
await this.filterManager.clearFilters();
} else {
await this.applyPreset(preset.name);
}
});
const presetName = document.createElement('span');
@@ -759,18 +765,6 @@ export class FilterPresetManager {
deleteBtn.innerHTML = '<i class="fas fa-times"></i>';
deleteBtn.title = translate('header.filter.presetDeleteTooltip', {}, 'Delete preset');
// Apply preset on name click (toggle if already active)
presetName.addEventListener('click', async (e) => {
e.stopPropagation();
this.cancelPendingDelete();
if (this.activePreset === preset.name) {
await this.filterManager.clearFilters();
} else {
await this.applyPreset(preset.name);
}
});
// Two-step delete on delete button click
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
+4
View File
@@ -281,6 +281,10 @@ export class LoadingManager {
// Initialize transfer stats with empty data
updateTransferStats();
if (this.cancelButton) {
this.loadingContent.appendChild(this.cancelButton);
}
// Return update function
return (currentProgress, currentIndex = 0, currentName = '', metrics = {}) => {
// Update current item progress
+13
View File
@@ -264,6 +264,19 @@ export class ModalManager {
});
}
// Add linkHfModal registration
const linkHfModal = document.getElementById('linkHfModal');
if (linkHfModal) {
this.registerModal('linkHfModal', {
element: linkHfModal,
onClose: () => {
this.getModal('linkHfModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add exampleAccessModal registration
const exampleAccessModal = document.getElementById('exampleAccessModal');
if (exampleAccessModal) {
+2 -1
View File
@@ -330,8 +330,9 @@ class MoveManager {
.filter(r => r.success)
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
// Deselect moving items
// Deselect moving items and exit bulk mode
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
if (state.bulkMode) bulkManager.toggleBulkMode();
} else {
// Single move mode
const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
+212 -5
View File
@@ -789,6 +789,27 @@ export class SettingsManager {
}
}
async _fetchProviderModelsAsync() {
try {
const resp = await fetch('/api/lm/llm/provider-models');
if (!resp.ok) return;
const data = await resp.json();
if (data.success && data.models) {
this._providerModels = data.models;
// Refresh model combobox if the settings modal is still open.
// Skip when provider is Ollama — it fetches its own live list
// from the local Ollama API and we must not overwrite it.
const llmProviderSelect = document.getElementById('llmProvider');
const provider = llmProviderSelect ? llmProviderSelect.value : 'openai';
if (this._llmModelCombobox && provider !== 'ollama') {
this._llmModelCombobox.updatePresets(this._providerModels[provider] || []);
}
}
} catch (_) {
// Silently ignore — models stay empty until next modal open
}
}
async loadSettingsToUI() {
// Set frontend settings from state
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
@@ -829,20 +850,112 @@ export class SettingsManager {
this.updateApiKeyStatus();
this.updateLlmApiKeyStatus();
// AI Provider settings
// ── AI Provider settings ──────────────────────────────────────
// Load provider presets from the JSON script tag embedded in the template
this._providerPresets = {};
this._providerModels = {};
const presetsScript = document.getElementById('llmProviderPresets');
if (presetsScript) {
try {
this._providerPresets = JSON.parse(presetsScript.textContent);
} catch (_) {
this._providerPresets = {};
}
}
const modelsScript = document.getElementById('llmProviderModels');
if (modelsScript) {
try {
this._providerModels = JSON.parse(modelsScript.textContent);
} catch (_) {
this._providerModels = {};
}
}
// If the embedded provider models is empty (server did not block on
// the remote catalog during page render), fetch asynchronously.
if (!this._providerModels || Object.keys(this._providerModels).length === 0) {
this._fetchProviderModelsAsync();
}
const llmProviderSelect = document.getElementById('llmProvider');
if (llmProviderSelect) {
llmProviderSelect.value = state.global.settings.llm_provider || 'openai';
}
// Destroy previous combobox instances before creating new ones,
// since loadSettingsToUI() runs on every modal open.
if (this._llmApiBaseCombobox) { this._llmApiBaseCombobox.destroy(); }
if (this._llmModelCombobox) { this._llmModelCombobox.destroy(); }
const llmApiBaseInput = document.getElementById('llmApiBase');
if (llmApiBaseInput) {
llmApiBaseInput.value = state.global.settings.llm_api_base || '';
const presetUrls = Object.values(this._providerPresets)
.map(p => p.api_base)
.filter(Boolean);
if (typeof Combobox !== 'undefined') {
this._llmApiBaseCombobox = new Combobox(llmApiBaseInput, {
presets: presetUrls,
placeholder: 'https://api.openai.com/v1',
});
}
}
// Helper to update model Combobox presets from catalog / Ollama API
const llmModelInput = document.getElementById('llmModel');
if (llmModelInput) {
llmModelInput.value = state.global.settings.llm_model || '';
this._llmModelCombobox = null;
if (llmModelInput && typeof Combobox !== 'undefined') {
const currentProvider = llmProviderSelect ? llmProviderSelect.value : 'openai';
const fallbackModels = currentProvider === 'ollama' ? [] : (this._providerModels[currentProvider] || []);
this._llmModelCombobox = new Combobox(llmModelInput, {
presets: fallbackModels,
placeholder: translate('settings.aiProvider.modelPlaceholder', {}, 'Select a model...'),
onSelect: (value) => {
state.global.settings.llm_model = value;
this.saveSetting('llm_model', value)
.then(() => showToast('toast.settings.settingsUpdated', { setting: 'model' }, 'success'))
.catch(() => {});
},
});
}
const _loadModelPresets = async (provider) => {
if (!this._llmModelCombobox) return;
if (provider === 'ollama') {
try {
const apiBase = document.getElementById('llmApiBase')?.value?.trim() || 'http://localhost:11434/v1';
const resp = await fetch(`/api/lm/llm/models?provider=ollama&api_base=${encodeURIComponent(apiBase)}`);
if (resp.ok) {
const data = await resp.json();
if (data.success && Array.isArray(data.models)) {
this._llmModelCombobox.updatePresets(data.models);
return;
}
}
} catch (_) {}
this._llmModelCombobox.updatePresets([]);
} else {
this._llmModelCombobox.updatePresets(this._providerModels[provider] || []);
}
};
_loadModelPresets(llmProviderSelect ? llmProviderSelect.value : 'openai');
// Provider change → auto-fill API Base URL + update model presets
if (llmProviderSelect) {
llmProviderSelect.addEventListener('change', () => {
const provider = llmProviderSelect.value;
const preset = this._providerPresets[provider];
if (preset) {
if (llmApiBaseInput && preset.api_base) {
llmApiBaseInput.value = preset.api_base;
if (this._llmApiBaseCombobox) {
this._llmApiBaseCombobox.setValue(preset.api_base);
}
llmApiBaseInput.dispatchEvent(new Event('blur'));
}
}
_loadModelPresets(provider);
});
}
const civitaiHostSelect = document.getElementById('civitaiHost');
@@ -1580,13 +1693,15 @@ export class SettingsManager {
<input type="text" class="extra-folder-path-input"
placeholder="${translate('settings.extraFolderPaths.pathPlaceholder', {}, '/path/to/models')}" value="${path}"
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
onfocus="settingsManager.clearExtraFolderPathError(this)"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
<button type="button" class="remove-path-btn"
onclick="this.parentElement.parentElement.remove(); settingsManager.updateExtraFolderPaths('${modelType}')"
onclick="settingsManager.removeExtraFolderPathRow(this, '${modelType}')"
title="${translate('common.actions.delete', {}, 'Delete')}">
<i class="fas fa-times"></i>
</button>
</div>
<div class="extra-folder-path-error"></div>
`;
container.appendChild(row);
@@ -1600,7 +1715,63 @@ export class SettingsManager {
}
}
clearExtraFolderPathError(input) {
input.classList.remove('has-error');
const row = input.closest('.extra-folder-path-row');
if (row) {
const errEl = row.querySelector('.extra-folder-path-error');
if (errEl) {
errEl.classList.remove('visible');
errEl.textContent = '';
}
}
}
_clearAllExtraFolderPathErrors() {
document.querySelectorAll('.extra-folder-path-input.has-error').forEach((input) => {
input.classList.remove('has-error');
});
document.querySelectorAll('.extra-folder-path-error.visible').forEach((el) => {
el.classList.remove('visible');
el.textContent = '';
});
}
_markExtraFolderPathsError(modelType, overlappingPaths, showMessage = false) {
const container = document.getElementById(`extraFolderPaths-${modelType}`);
if (!container) return;
const inputs = container.querySelectorAll('.extra-folder-path-input');
inputs.forEach((input) => {
const val = input.value.trim();
if (val && overlappingPaths.includes(val)) {
input.classList.add('has-error');
if (showMessage) {
const row = input.closest('.extra-folder-path-row');
if (row) {
const errEl = row.querySelector('.extra-folder-path-error');
if (errEl) {
errEl.textContent = translate('settings.extraFolderPaths.validation.checkpointUnetOverlapInline', {}, 'This path is also used for a different model type. Use separate folders for checkpoints and diffusion models.');
errEl.classList.add('visible');
}
}
}
}
});
}
removeExtraFolderPathRow(btn, modelType) {
const row = btn.closest('.extra-folder-path-row');
if (row) {
row.remove();
this.updateExtraFolderPaths(modelType);
}
}
async updateExtraFolderPaths(changedModelType) {
// Clear previous errors
this._clearAllExtraFolderPathErrors();
const extraFolderPaths = {};
// Collect paths for all model types
@@ -1621,6 +1792,32 @@ export class SettingsManager {
extraFolderPaths[modelType] = paths;
});
// Client-side pre-check: checkpoints and unet must not share the same path.
// Normalise paths to reduce false negatives vs the backend's realpath + normcase.
const normalise = (p) => p.replace(/[/\\]+$/, '').toLowerCase();
const ckptSet = new Set((extraFolderPaths.checkpoints || []).map(normalise));
const unetSet = new Set((extraFolderPaths.unet || []).map(normalise));
const ckptOverlap = (extraFolderPaths.checkpoints || []).filter(p => p && unetSet.has(normalise(p)));
const unetOverlap = (extraFolderPaths.unet || []).filter(p => p && ckptSet.has(normalise(p)));
const hasOverlap = ckptOverlap.length > 0 || unetOverlap.length > 0;
if (hasOverlap) {
// Error message only on the side the user just edited.
// The other side gets red border only (passive conflict indicator).
if (changedModelType === 'checkpoints') {
this._markExtraFolderPathsError('checkpoints', ckptOverlap, true);
this._markExtraFolderPathsError('unet', unetOverlap, false);
} else if (changedModelType === 'unet') {
this._markExtraFolderPathsError('unet', unetOverlap, true);
this._markExtraFolderPathsError('checkpoints', ckptOverlap, false);
} else {
// Pre-existing conflict from direct config edit — mark both without messages
this._markExtraFolderPathsError('checkpoints', ckptOverlap, false);
this._markExtraFolderPathsError('unet', unetOverlap, false);
}
return;
}
// Check if paths have actually changed
const currentPaths = state.global.settings.extra_folder_paths || {};
const pathsChanged = JSON.stringify(currentPaths) !== JSON.stringify(extraFolderPaths);
@@ -2149,6 +2346,16 @@ export class SettingsManager {
enableMetadataArchiveCheckbox.checked = state.global.settings.enable_metadata_archive_db || false;
}
const enableCivarchiveApiCheckbox = document.getElementById('enableCivarchiveApi');
if (enableCivarchiveApiCheckbox) {
enableCivarchiveApiCheckbox.checked = state.global.settings.enable_civarchive_api ?? true;
}
const metadataProviderOrderSelect = document.getElementById('metadataProviderOrder');
if (metadataProviderOrderSelect) {
metadataProviderOrderSelect.value = state.global.settings.metadata_provider_order || 'civitai_archive_sqlite';
}
// Load status
await this.updateMetadataArchiveStatus();
} catch (error) {
@@ -2949,7 +3156,7 @@ export class SettingsManager {
}
updateLlmApiKeyStatus() {
const hasKey = !!state.global.settings.llm_api_key;
const hasKey = !!(state.global.settings.llm_api_key_set || state.global.settings.llm_api_key);
const statusText = document.getElementById('llmApiKeyStatusText');
const actionBtn = document.getElementById('llmApiKeyActionBtn');
if (!statusText || !actionBtn) return;
+29 -8
View File
@@ -168,6 +168,18 @@ export class DownloadManager {
let failedDownloads = 0;
let accessFailures = 0;
let currentLoraProgress = 0;
let cancelled = false;
this.importManager.loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
try {
const loraClient = getModelApiClient(MODEL_TYPES.LORA);
await loraClient.cancelDownload(batchDownloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
});
// Set up progress tracking for current download
ws.onmessage = (event) => {
@@ -179,6 +191,11 @@ export class DownloadManager {
return;
}
if (data.status === 'cancelled') {
cancelled = true;
return;
}
// Process progress updates for our current active download
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
// Update current LoRA progress
@@ -221,6 +238,8 @@ export class DownloadManager {
const useDefaultPaths = getStorageItem('use_default_path_loras', false);
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
if (cancelled) break;
const lora = this.importManager.downloadableLoRAs[i];
// Reset current LoRA progress for new download
@@ -241,15 +260,13 @@ export class DownloadManager {
batchDownloadId
);
if (cancelled) break;
if (!response.success) {
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
failedDownloads++;
// Continue with next download
} else {
completedDownloads++;
// Update progress to show completion of current LoRA
updateProgress(100, completedDownloads, '');
if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) {
@@ -259,9 +276,10 @@ export class DownloadManager {
}
}
} catch (downloadError) {
console.error(`Error downloading LoRA ${lora.name}:`, downloadError);
failedDownloads++;
// Continue with next download
if (!cancelled) {
console.error(`Error downloading LoRA ${lora.name}:`, downloadError);
failedDownloads++;
}
}
}
@@ -269,7 +287,10 @@ export class DownloadManager {
ws.close();
// Show appropriate completion message based on results
if (failedDownloads === 0) {
if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
if (accessFailures > 0) {
+2
View File
@@ -13,6 +13,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
language: 'en',
show_only_sfw: false,
enable_metadata_archive_db: false,
enable_civarchive_api: true,
metadata_provider_order: 'civitai_archive_sqlite',
proxy_enabled: false,
proxy_type: 'http',
proxy_host: '',

Some files were not shown because too many files have changed in this diff Show More