Compare commits

...

60 Commits

Author SHA1 Message Date
Will Miao 63785f82b5 refactor(agent): consolidate skill definition into single SKILL.md with YAML frontmatter
Merge skill.yaml (metadata) and prompt.md (prompt template) into a
single SKILL.md file with YAML frontmatter, matching the agent-skill
convention used by opencode and Claude Code.

- Add frontmatter parser (_parse_skill_file) to SkillRegistry
- Remove skill.yaml, prompt.md, empty skills/__init__.py
- Remove obsolete load_handler method
- Update tests for new format and cleaned-up fields
2026-07-02 21:29:02 +08:00
Will Miao cf898da193 feat(agent): add LLM-powered metadata enrichment system with AgentCLI and PostProcessor
Introduce an agent skill framework for LLM-driven metadata enrichment:

- AgentCLI (py/agent_cli/): in-process wrappers around internal services
  using standard relative imports, eliminating the need for sys.path hacks
- LLMService: centralized BYOK (bring-your-own-key) LLM client supporting
  OpenAI, Ollama, and custom OpenAI-compatible endpoints
- PostProcessor: deterministic engine that applies LLM output via AgentCLI
  (replaces old handler.py + _BASE_MODEL_ALIASES approach)
- SkillRegistry: filesystem-based skill discovery (skill.yaml + prompt.md)
- AgentService: orchestrates skill execution with WebSocket progress
- Frontend AgentManager: WebSocket listeners, skill execution, config UI
- Context menu entries (single + bulk) for "Enrich Metadata (Agent)"
- Settings UI for AI Provider configuration (BYOK)
- Full i18n support across 9 locales

Bug fixes found during review:
- aiohttp.web.json_response: status_code= -> status=
- settings_modal cancelEditApiKey: wrong argument position
- AgentManager.isLlmConfigured: allow Ollama without API key
- PostProcessor._merge_tags: lowercase all tags to match TagUpdateService
2026-07-02 21:27:01 +08:00
Will Miao fe90f7f9b1 feat(ui): add searchable base model dropdown with filename inference in model modal
Replace native <select> with a searchable dropdown that:
- Filters options as the user types
- Shows filename-inferred suggestions at the top in a "Suggested" section
- Supports keyboard navigation (ArrowUp/Down/Enter/Escape)
- Allows typing custom values not in the list
- Removes dead .base-model-selector CSS

Adds 3 new i18n keys (baseModelSearchPlaceholder, baseModelSuggested,
baseModelNoMatch) with translations for all 9 locales.
2026-07-01 14:31:08 +08:00
Will Miao 8b344ea39f feat(ui): add View on Hugging Face button, plumb hf_url through full cache pipeline 2026-07-01 08:38:16 +08:00
Will Miao 8348a0cef8 fix(download): harden HF download path validation, fix WebSocket leak, add URL detection tests (#965, #977)
Security hardening:
- Validate repo format with strict regex (reject .. traversal)
- Validate filename rejects path separators and ..
- Validate relative_path rejects absolute paths and ..
- Verify model_root is within configured scanner roots using
  realpath + os.sep guard to prevent prefix-match bypass
- Add realpath-based escape detection for final dest_path

Bug fixes:
- Fix WebSocket leak in _downloadHfSingle: wrap ws.close() in
  try/finally so it closes even if downloadHfModel() throws
- Same fix for batch HF download per-file WebSocket loop

Frontend hardening:
- Tighten HF repo regex: require huggingface.co for full URLs,
  reject bare .. patterns
- Add 12 unit tests for detectUrlType() covering HF resolve,
  HF repo, CivitAI, CivArchive, direct HTTP, edge cases
2026-07-01 05:51:58 +08:00
Will Miao 7cf785b72f fix(ui): unify HF file selection UI, remove cloud icon, add select-all, cleanup dead code (#965, #977)
- Unify single-URL and multi-URL HF repo flows to use the same batch
  preview interface (remove separate repoFileStep)
- Remove unnecessary cloud icon from HF batch preview items
- Use formatFileSize() instead of hardcoded MB text
- Change default selection to unchecked (no preselected files)
- Add select all / deselect all checkbox with dynamic Next button
- Clean up dead CSS, HTML template, and JS methods from removed
  repoFileStep
- Add selectAll i18n key with translations for all 10 locales
- Fix batch progress bar name fallback for HF items
2026-06-30 23:28:35 +08:00
Will Miao e8913f4481 feat(ui): dynamically populate base model dropdown from CivitAI API, add Krea 2 constants (#1001) 2026-06-30 22:41:17 +08:00
Will Miao f9c3d8dc97 fix(metadata): demote CivArchive hash lookup failure from ERROR to DEBUG
A model not being found on CivArchive by hash is a routine case (the
model simply isn't published there), not an error. The callers already
log the outcome at WARNING (bulk_metadata_refresh) or DEBUG
(metadata_sync_service) with full context, making this ERROR-level log
both misleading and redundant.
2026-06-30 19:42:30 +08:00
Will Miao 09ca91fc0e feat(download): add Hugging Face model download to standalone UI wizard (#965, #977)
Integrate HF model downloading into the existing CivitAI-style wizard flow:
- URL type detection (civitai / hf-resolve / hf-repo / direct-http)
- Repo file explorer with checkbox-based file selection
- Batch/queue download with per-file WebSocket progress
- Aria2 backend support (respects download_backend setting)
- Scanner cache integration via create_default_metadata + add_model_to_cache
- i18n updates for all 10 locales
2026-06-30 19:36:12 +08:00
Will Miao 16f5222efd fix(cache): prevent corrupted cache rows from breaking model listings (#730)
Cache corruption (NULL model_name/file_name from legacy DB rows or partial
writes) caused format_response to raise KeyError/AttributeError, failing the
entire /loras/list request and showing no models in the UI.

Fix across three layers:
- format_response (lora/checkpoint/embedding): replace direct dict[] access
  with .get() fallbacks; return None for entries missing file_path
- handlers: filter None entries from list/excluded/fetch/duplicate/conflict
  endpoints instead of letting them crash or appear as null in responses
- model_scanner: always use validate_batch repaired copies (previously
  discarded when no invalid entries, leaving None values in raw_data)
- persistent_model_cache: add or-empty-string guards on read and write for
  nullable TEXT columns (model_name, file_name, folder, base_model, etc.)
2026-06-30 09:02:42 +08:00
Will Miao 28e7c04b37 fix(settings): migrate all settings subdirectories on portable mode switch 2026-06-29 21:40:37 +08:00
Will Miao 28f99c46d3 fix(update): preserve user data dirs during Git-based update via git clean -e excludes
git clean -fd in _perform_git_update deleted untracked, non-ignored
directories (wildcards, stats, backups, civitai, caches, logs) during
portable-mode updates, since released tags do not list them in .gitignore.
Add -e excludes for all user-managed paths to both nightly and stable
update branches. Add regression tests for both paths.
2026-06-29 21:10:38 +08:00
Will Miao 205194f4e6 chore: add stats, wildcards, backups, and logs dirs to .gitignore 2026-06-29 19:46:04 +08:00
willmiao 402d8b07cf docs: auto-update supporters list in README 2026-06-28 14:17:19 +00:00
Will Miao 3e303ab316 chore(release): bump version to v1.1.6 2026-06-28 22:17:02 +08:00
Will Miao e9e8c31ad1 fix(registry): store nodes per-client to prevent multi-tab race condition
Move NodeRegistry from a single global _nodes dict to a per-client
(_tab_nodes) structure so that multiple ComfyUI browser tabs no
longer overwrite each other's workflow node data during a
lora_registry_refresh cycle.  The merged result is a union of all
known tabs' target nodes, eliminating the non-deterministic failure
where send-to-workflow could randomly target a tab lacking valid
targets.

- NodeRegistry.register_nodes(sid, nodes) replaces per-tab data
  without affecting other tabs.
- NodeRegistry.get_merged_registry() returns the union across all
  connected clients, together with tab_count / per-tab metadata.
- prepare_for_refresh() snapshots the current active sockets; caller
  re-reads before merging so that newly-connected tabs are not pruned.
- workflow_registry.js sends api.clientId in the POST body so the
  backend can identify which tab is registering.
2026-06-28 17:57:58 +08:00
Will Miao 703a6a4ea0 fix(import): request withMeta=true from CivitAI API, fix checkpoint type guard and CivArchive version lookup
- Add &withMeta=true to image info URL so API returns full generation
  metadata (resources with hash/type) instead of null meta
- Fix checkpoint assignment guard: check modelId instead of id so non-
  checkpoint types (upscaler) are not wrongly set as recipe checkpoint
- Skip modelVersionIds loop when resources/civitaiResources already
  provided LoRAs, preventing hash-resolved duplicates
- Fix int/str type comparison in CivArchive get_model_version so
  version ID matching works correctly
2026-06-27 22:22:48 +08:00
Will Miao 283730cf38 fix(import): discover LoRA + checkpoint from modelVersionIds when API meta is null
When CivitAI image API returns meta=null and modelVersionIds at root
level, the import flow now:

- Injects modelVersionIds + browsingLevel into a minimal metadata dict
  so the parser can discover LoRAs and checkpoints (both import-from-url
  and analyze-image paths)
- Adds checkpoint dedup + fallback in the parser's modelVersionIds
  handler to avoid duplicate API calls
- Runs EXIF extraction unconditionally in analyze-image path, then
  merges with API metadata (fixes gen params loss)
- Propagates preview_nsfw_level through all three import paths:
  import-from-url, analyze-image (UI Import), and batch-import,
  plus the frontend save flow
2026-06-27 17:05:38 +08:00
Will Miao 20417797e8 fix(download): accept UNet and Diffusion Model file types from CivitAI
- Prefer file type (UNet/Diffusion Model) over baseModel name when
  deciding whether a checkpoint routes to the unet folder
- Add UNet to backend primary file type whitelist
- Add Krea 2 to DIFFUSION_MODEL_BASE_MODELS
- Include UNet/Diffusion Model files in frontend file selection UI
- Use actual file type from CivitAI in download params instead of
  hardcoded 'Model'
2026-06-27 08:56:11 +08:00
Will Miao 004c69b9ef fix(marquee): use document coordinates, add auto-scroll, support VirtualScroller off-screen cards
- Convert marquee selection from viewport to document coordinates so
  scrolling during a drag no longer deselects off-screen cards.
- Add RAF-based auto-scroll when dragging near viewport edges.
- Compute off-screen card positions from VirtualScroller layout
  parameters instead of relying on DOM queries.
2026-06-27 08:21:21 +08:00
Will Miao 47fe2d3783 chore: remove deprecated reference files from refs/ 2026-06-27 07:02:22 +08:00
Will Miao 36ef840a22 fix(parser): merge Lora hashes over empty Hashes JSON values and skip entries without hash 2026-06-26 22:31:36 +08:00
Will Miao 09c2445ac9 fix(ui): prevent scroll jump on model card click caused by sort dropdown focus
The document-level click handler in SortDropdown.js called trigger.focus()
unconditionally on every click outside the sort group. When a model card
was clicked to open the modal, focus() triggered scrollIntoView on the
.sort-trigger button, perturbing .page-content.scrollTop and causing the
card grid to jump up a few pixels.

The same interference also broke the back-to-top smooth-scroll animation:
frame-by-frame focus/scroll perturbations caused VirtualScroller to
schedule repeated re-renders, interrupting the compositor-thread scroll.

Fix: only return focus to the trigger when the dropdown was actually open,
so ordinary page clicks (e.g. clicking a model card) never force focus.
2026-06-26 19:40:12 +08:00
Will Miao 8a6d23f9c7 Revert "fix(ui): replace smooth scroll with instant for back-to-top to avoid VirtualScroller conflict"
This reverts commit a429e6b1c3.
2026-06-26 19:36:08 +08:00
Will Miao 3d207b6744 fix(updates): mark cross-folder versions as in-library during folder-filtered refresh (#997)
When refreshing updates with a folder filter, versions already present in
other folders were excluded from the is_in_library check, making them
appear as available updates. When the user tried to download, the global
check found the file already exists and returned 'model already exists'.

Fix by also collecting the cross-folder version set when folder_path is
provided, and using the union (folder-filtered + cross-folder) for
is_in_library in both _build_record_from_remote and
_merge_with_local_versions.
2026-06-26 17:40:41 +08:00
Will Miao b3edda62ad refactor(ui): persist sort per-mode with two storage keys, add recipes sort persistence 2026-06-26 17:07:17 +08:00
Will Miao a429e6b1c3 fix(ui): replace smooth scroll with instant for back-to-top to avoid VirtualScroller conflict
The back-to-top button used scrollTo({top:0, behavior:'smooth'}) which
conflicts with VirtualScroller's DOM manipulations during the smooth
scroll animation. Each animation frame triggered handleScroll() ->
scheduleRender() -> renderItems(), causing the browser to interrupt
the smooth scroll animation mid-way, resulting in only ~1 page of
upward scroll instead of reaching the top.

Root cause: commit 311e89e9 fixed VirtualScroller to listen on the
correct scroll container (.page-content), but this meant every scroll
event during smooth animation now triggers expensive DOM operations
that abort the browser's compositor-thread smooth scroll animation.

Fix: use instant scroll (scrollTop = 0) so the position is set
immediately without triggering frame-by-frame VirtualScroller
interference.
2026-06-26 16:31:31 +08:00
Will Miao c1bf9c6221 test(aria2): verify _wait_until_ready captures stderr on subprocess early exit
Regression test for the pipe-race bug where _drain_stderr consumed
aria2's error output before _wait_until_ready could read it.
2026-06-26 14:41:32 +08:00
Will Miao 75fffc1e25 fix(aria2): move stderr drain after _wait_until_ready to avoid swallowing startup errors
_drain_stderr and _wait_until_ready both read from the same stderr pipe.
Starting the drain task before _wait_until_ready creates a race where the
drain task consumes aria2's early-exit error message before the startup
waiter can read it, resulting in an empty error message in the logs.

Also confirmed that --fsync does not exist as an aria2 option (exit code
28 = Invalid argument).
2026-06-26 14:32:43 +08:00
Will Miao f264bab65c fix(aria2): remove --fsync=false to avoid crash on older aria2c versions
Exit code 28 (Invalid argument) indicates this user's aria2c does not
support the --fsync option. Remove it unconditionally; the stderr drain,
relaxed RPC timeouts, and increased retry coverage remain in place.
2026-06-26 14:24:46 +08:00
Will Miao 154fcd803b fix(aria2): disable fsync and relax RPC timeouts to prevent aria2 freeze on large files
aria2 default --fsync=true calls fsync() after each write, which blocks
the entire single-threaded process on large files under Docker overlay.
Add --fsync=false to eliminate this blocking source.

Relax aiohttp session timeout: total=30 → sock_connect=10, sock_read=60
so that transient I/O delays don't cut off legitimate tellStatus RPCs.

Increase retry params (4 attempts, 3s delay) to give aria2 more recovery
time when blocked on synchronous I/O.
2026-06-26 14:19:37 +08:00
Will Miao 4ef32d3a96 fix(ui): prevent bulk-mode highlight from being clipped on edge cards 2026-06-26 11:59:28 +08:00
Will Miao d2d109a69c feat(ui): replace native sort select with custom dropdown sized to selected text 2026-06-26 09:53:04 +08:00
Will Miao 3a2941d751 fix(aria2): drain stderr pipe to prevent aria2 freeze, retry RPC status on transient failure
Root cause: aria2c subprocess stderr pipe (64 KB buffer) was never
drained. When enough error/warning output accumulated, aria2's write()
blocked, freezing the entire process including its RPC handler. The
tellStatus call then timed out after 30s with asyncio.TimeoutError(),
producing the empty error message in 'Failed to query aria2 download
status: '.

Fixes:
- Drain stderr in a background task so pipe never fills up
- Retry get_status() RPC calls up to 3 times on transient failure
- In the failure path, preserve .safetensors when .aria2 is absent
  (the download was likely complete on disk)
2026-06-26 08:25:05 +08:00
Will Miao 0ac10dfd42 fix(ui): prevent Launch LoRA Manager button from disappearing when opening properties panel in subgraph (#996) 2026-06-25 20:47:29 +08:00
Will Miao 9c95856b2f fix(trigger-wheel): prevent Vue render mode from intercepting strength wheel events
In Vue render mode, ComfyUI's TransformPane uses a capture-phase wheel
handler (@wheel.capture) that fires before the tag element's bubble-phase
strength-adjustment listener. It checks wheelCapturedByFocusedElement(),
which requires data-capture-wheel on a focused element. The tag divs had
data-capture-wheel but were not focusable, so the check failed, causing
the capture handler to forward the event to the canvas (triggering zoom)
and stopPropagation() which prevented the strength handler from running.

Fix: move data-capture-wheel from individual tags to the container, make
it focusable (tabIndex=-1), and add a window-level capture-phase wheel
listener that focuses the container before TransformPane checks it.
2026-06-25 14:58:20 +08:00
Will Miao 5ce4667d32 feat(node-marker): add 🎯 emoji prefix to Mark as context menu item 2026-06-24 22:36:45 +08:00
willmiao be53fda6df docs: auto-update supporters list in README 2026-06-24 14:11:36 +00:00
Will Miao f48de05102 chore(release): bump version to v1.1.5 2026-06-24 22:11:17 +08:00
Will Miao 93ad81ed87 fix(ui): replace full-page loading overlay with grid-scoped loader to eliminate flicker
- Add .grid-loading-overlay CSS: position:absolute inside card grid,
  semi-transparent dark background, z-index 100, pointer-events:none
- Add showGridLoading() / hideGridLoading() to VirtualScroller:
  creates/removes the scoped overlay inside the card grid only
- Modify loadMoreWithVirtualScroll(): replace full-page
  state.loadingManager overlay with grid-scoped loading, defer
  hide via requestAnimationFrame to eliminate blank-frame gap
- Clean up gridLoadingOverlay in dispose() to prevent DOM leak
2026-06-24 21:11:13 +08:00
Will Miao ea14d211be refactor(ui): unify search bar placeholder to i18n key header.search.placeholder
- Replace page-specific header.search.placeholders.* keys with a single
  header.search.placeholder key (value: "Search", no ellipsis)
- Keep header.search.notAvailable for the statistics page
- Remove unused placeholder/placeholders/notAvailable entries from all
  10 locale files; preserve options and searchIn keys
- Update Jinja template and JS header to use the new unified key
2026-06-24 20:30:38 +08:00
Will Miao 8052cefd46 feat(ui): add keyboard shortcut cue in search bar, fix clear button positioning 2026-06-24 20:21:15 +08:00
Will Miao 845815b9b7 fix(flash): fix text widget flash in Vue mode, add fade and hover dismissal
- Fix Vue mode: text widgets (CLIPTextEncode, Prompt LM) had no
  [data-testid=widget-layout-field-label], so findRowEl never matched.
  Added fallback strategies: bare <label> text match and widget index match.
- Fix Vue mode: flash background pulse was never applied — @keyframes was
  defined but no rule bound it to .lm-flash. Replaced with CSS transition
  on .lm-flash-host class for value text color fade-in/fade-out.
- Fix Vue mode: -webkit-text-fill-color set by ComfyUI overrode
  even with !important. Added -webkit-text-fill-color override to .lm-flash.
- Fix canvas mode: highlight rect was double-offset because onDrawForeground
  ctx is pre-translated to node.pos. Removed background rect entirely per
  design decision; kept text_color + inline color only.
- Add fade-in (250ms) / fade-out (400ms) for text color in both modes.
  Canvas-drawn widgets use rAF color interpolation; DOM widgets use CSS
  transition. Fixed hexToRgb to handle 3-digit hex shorthand (#DDD).
- Add hover dismissal to canvas mode via app.canvas.getWidgetAtCursor().
  Vue mode already had it via mouseover listener.
- Replace 60fps rAF poll with 100ms setInterval for hover detection.
- Fix batch cleanup closure bug: isDomWidget evaluated per-widget instead
  of per-call; fade rAF cancellers tracked per-widget in _lmFadeCancels map.
- Unify flash color from #66B3FF to LM brand accent #4299E0.
- Fix Vue fade-out: keep .lm-flash-host 300ms after removing .lm-flash so
  CSS transition persists. Canvas DOM widgets: keep inline transition 300ms
  after clearing color.
2026-06-24 19:35:30 +08:00
Will Miao 609dc5d783 feat(sort): enable versions_count sort in non-grouped mode
Sort by Most/Fewest versions first now works when Group by model is off.

- Backend: group items by modelId (respecting version_grouping setting),
  count versions per group, sort groups by count, expand groups with
  versions sorted by version id descending
- CSS: remove rule that hid the sort option in non-grouped mode
- Tests: add 3 tests covering desc, asc, and same_base variants
2026-06-24 17:14:39 +08:00
Will Miao 7a71b34b54 feat(vlm): sort versions by newest first in VLM view, with disabled sort dropdown
When viewing all versions of a model (VLM mode via 'x versions' button):
- Backend always sorts by version ID descending, ignoring current sort_by
- A temporary 'Newest version first' option is injected into the sort
  dropdown (removed on exit, not a permanent option)
- The sort dropdown is disabled (greyed out) while VLM is active
- On clearing VLM, the previous sort preference is restored and the
  dropdown re-enabled
- Handles stale VLM state (e.g. after page reload with leftover session)
- Covers all three model page types: loras, checkpoints, embeddings

Also fixes review nits:
- Correct i18n call pattern (defaultValue in options object)
- Shared _restoreSortAfterVlm() helper to avoid triple duplication
2026-06-24 16:25:14 +08:00
Will Miao 71a459422f feat: send gen params to workflow with visual cues
- Add genParamsMapper.js: sampler/scheduler display→internal mapping,
  combined-name parsing, widget matching
- Add sendGenParamsToWorkflow() in uiHelpers.js: resolves sampler,
  fetches registry by send_gen_params marker, sends via update-node-widget
- Add send-params-btn UI in showcase hover panel and recipe modal
- Add flashWidget() in workflow_registry.js: text-color visual cue
  on updated widget values (Vue: inline style + CSS, canvas: property shadow)
- Add silent option to sendWidgetValueToNodes for consolidated toast
- Normalize param display labels (cfg_scale→CFG, etc.) in recipe modal
- Add 33 tests for genParamsMapper; update existing test assertions
2026-06-24 15:39:57 +08:00
Will Miao cd2628a0ee feat(ui): add send-prompt-to-workflow button for prompt and negative prompt
- Add sendPromptToWorkflow() and stripLoraTags() exports to uiHelpers.js
- Add send button (paper-plane icon) to recipe modal and showcase hover panel
- Restructure showcase metadata panel layout to match recipe modal style
- Respect strip <lora:> setting before sending
- Uses 'replace' mode (not append) on text-capable workflow nodes
- Add translations for all 10 locales
2026-06-23 21:36:24 +08:00
Will Miao 85da7175bc feat: add Node Marker system with right-click marking 2026-06-23 20:54:32 +08:00
Will Miao d3bf0a164b fix(gitignore): add .reasonix/ to ignore list 2026-06-23 07:06:15 +08:00
Will Miao afb6ca1b8d refactor(settings): rename update_flag_strategy to version_grouping with migration 2026-06-22 16:59:32 +08:00
Will Miao 94f43426d7 feat(ui): show version count in group-by-model cards, add versions_count sort, no-reload VLM
- group_by_model dedup now counts versions per group and attaches
  version_count; respects update_flag_strategy (same_base) by
  sub-grouping on base_model
- Card footer shows clickable 'x versions' link instead of version
  name when grouped (hides HIGH/LOW badges); clicking triggers
  View Local Versions without page reload
- Added 'Local Versions' sort option (versions_count), auto-hidden
  when group_by_model is off
- Sort preference is saved/restored separately for normal and
  grouped modes
- VLM flow (triggerVlmView, clearCustomFilter) uses resetAndReload()
  via API instead of window.location.reload()
- Fixed cache mutation bug: version_count is now set on a shallow
  copy, not the cached dict, preventing stale version_count leaking
  into VLM responses
- i18n: all 9 locale files translated
2026-06-22 16:02:12 +08:00
Will Miao 2b361f4f5d feat(ui): add group-by-model toggle to global context menu
Adds a 'Group by Model' toggle entry to the right-click global context
menu for quick access, complementing the existing setting in
Settings → Layout Settings. The menu item shows a checkmark indicator
reflecting the current state and immediately reloads the view on toggle.

Also fixes he.json translation that was mojibake (garbled characters).

Includes:
- Context menu HTML item with check-indicator
- JS toggle logic via settingsManager
- i18n for all 10 locales
- Hebrew translation fix
2026-06-22 11:31:15 +08:00
Will Miao 7438072f8c feat(save-image): add %batch_num% support in batch loop 2026-06-22 09:11:38 +08:00
Will Miao 26c54fd358 fix(versions): scope VLM custom filter per-page to prevent cross-page leak
Store the originating page type alongside VLM data in sessionStorage;
validate it on every page load before applying the filter or showing
the indicator. Stale data is auto-cleaned on mismatch.

This prevents the 'View all local versions' custom filter from leaking
into the checkpoints (or embeddings) page, which caused an empty grid.
2026-06-21 12:02:06 +08:00
Will Miao 7cb6b04c63 chore: remove duplicate _truncateText from LorasControls/CheckpointsControls, add backend test for civitai_model_id filter 2026-06-21 11:19:54 +08:00
Will Miao fc29cde82a feat(versions): add View all local versions button to model versions tab
Clicking the button closes the modal, writes filter params to sessionStorage,
and reloads the page to show all local versions of the model as individual
cards (bypassing group-by-model dedup). The filter respects the update flag
strategy and the versions-filter-toggle state (same-base vs all versions).

Supporting changes:
- sessionStorage keys vlm_model_id / vlm_model_name / vlm_base_model
- BaseModelApiClient._addModelSpecificParams adds civitai_model_id param
- LoraApiClient calls super._addModelSpecificParams for VLM detection
- LorasControls / CheckpointsControls clearCustomFilter checks VLM first
- PageControls.checkVlmFilter shows customFilterIndicator with label
- Backend parses civitai_model_id, filters before group_by_model dedup
2026-06-21 11:13:53 +08:00
Will Miao 559ca946dc feat(models): add group-by-model option to collapse multiple versions into one card
Adds a 'Group by Model' toggle in Layout Settings. When enabled, only the
latest version (highest civitai.id) of each Civitai model is shown as a
single card — older versions sharing the same modelId are hidden.

Backend dedup runs in BaseModelService.get_paginated_data() before
filtering/pagination, ensuring correct paginated results. The setting
is persisted via the existing settings pipeline and passed as a query
parameter to the listing endpoint.

Includes:
- Backend: dedup logic, route param parsing, settings default
- Frontend: API param, SettingsManager wiring, toggle UI
- i18n: translations for all 10 locales
- Tests: unit test covering dedup on/off and standalone items
2026-06-21 08:48:42 +08:00
Will Miao 2b8e7c7504 fix(tests): update recipes page tests for unified controls template
- Inject #customFilterIndicator DOM in beforeEach (raw template
  renderer doesn't process Jinja2 {% include %} tags)
- Fix selector from #customFilterText to .customFilterText
2026-06-20 06:55:47 +08:00
Will Miao 6816d75933 refactor(recipes): unify controls and breadcrumb UI with model pages
- Replace inline controls+breadcrumb in recipes.html with shared includes
- Add page_id conditionals in controls.html to adapt buttons per page type
- Unify customFilterText selector to class-based in recipes.js
- Add [data-action="find-duplicates"] event listener for unified button
- Fix i18n keys to use recipes-specific translations on recipes page
2026-06-19 22:41:50 +08:00
willmiao b58abbad7c docs: auto-update supporters list in README 2026-06-19 10:31:18 +00:00
138 changed files with 12948 additions and 3923 deletions
+5
View File
@@ -7,6 +7,10 @@ py/run_test.py
.vscode/
cache/
civitai/
stats/
wildcards/
backups/
logs/
node_modules/
coverage/
.coverage
@@ -19,6 +23,7 @@ model_cache/
.codex
.omo
reasonix.toml
.reasonix/
.codegraph/
# Vue widgets development cache (but keep build output)
+2 -2
View File
File diff suppressed because one or more lines are too long
+269 -256
View File
@@ -20,33 +20,53 @@
"Carl G.",
"stone9k",
"Rosenthal",
"Francisco Tatis",
"JongWon Han",
"FreelancerZ",
"Polymorphic Indeterminate",
"Skalabananen",
"Marc Whiffen",
"Birdy",
"itismyelement",
"Mozzel",
"Gingko Biloba",
"Kiba",
"Reno Lam",
"onesecondinosaur",
"sig",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"J\\B/ 8r0wns0n",
"ClockDaemon",
"Francisco Tatis",
"KD",
"Omnidex",
"Tyler Trebuchon",
"Release Cabrakan",
"Tobi_Swagg",
"SG",
"James Dooley",
"zenbound",
"Buzzard",
"jmack",
"Andrew Wilson",
"Greybush",
"Mark Corneglio",
"Ricky Carter",
"JongWon Han",
"James Todd",
"Steven Pfeiffer",
"VantAI",
"レプサイ",
"Lisster",
"Michael Wong",
"runte3221",
"Illrigger",
"Tom Corrigan",
"JackieWang",
"FreelancerZ",
"fnkylove",
"Yushio",
"Vik71it",
"Echo",
"Lilleman",
"Robert Stacey",
@@ -54,176 +74,161 @@
"Edgar Tejeda",
"Fraser Cross",
"Liam MacDougal",
"Polymorphic Indeterminate",
"Sterilized",
"BadassArabianMofo",
"JORGE+LUIZ+HUSSNI+MESSIAS",
"Marc Whiffen",
"Skalabananen",
"Birdy",
"quarz",
"Reno Lam",
"Greg",
"jean jahren",
"JSST",
"sig",
"J\\B/ 8r0wns0n",
"Snaggwort",
"lmsupporter",
"Takkan",
"wfpearl",
"Matt+J",
"Baekdoosixt",
"Jonathan Ross",
"KD",
"Omnidex",
"Jack B Nimble",
"Nazono_hito",
"Melville Parrish",
"daniel dove",
"Lustre",
"Tyler Trebuchon",
"Release Cabrakan",
"JW Sin",
"Alex",
"bh",
"carozzz",
"Marlon Daniels",
"James Dooley",
"zenbound",
"Buzzard",
"Starkselle",
"Aaron Bleuer",
"LacesOut!",
"greebles",
"Adam Shaw",
"Mark Corneglio",
"SarcasticHashtag",
"Anthony Rizzo",
"iamresist",
"M Postkasse",
"RedrockVP",
"Wolffen",
"James Todd",
"Wicked Choices by ASLPro3D",
"Jacob Hoehler",
"FinalyFree",
"Weasyl",
"Steven Pfeiffer",
"Timmy",
"Johnny",
"Cory Paza",
"Tak",
"Lisster",
"Big Red",
"whudunit",
"Luc Job",
"dl0901dm",
"Philip Hempel",
"corde",
"nwalker94",
"Yushio",
"Vik71it",
"Bishoujoker",
"aai",
"Todd Keck",
"Briton Heilbrun",
"Tori",
"wildnut",
"Aleksander Wujczyk",
"AM Kuro",
"BadassArabianMofo",
"Pascal Dahle",
"Greg",
"Sangheili460",
"MagnaInsomnia",
"Akira_HentAI",
"lmsupporter",
"Karl P.",
"andrew.tappan",
"N/A",
"The Spawn",
"graysock",
"Greenmoustache",
"zounic",
"wfpearl",
"Eldithor",
"Jack B Nimble",
"fancypants",
"Digital",
"JaxMax",
"contrite831",
"Jwk0205",
"Starkselle",
"Bro Xie",
"batblue",
"carey6409",
"Olive",
"LacesOut!",
"greebles",
"太郎 ゲーム",
"Some Guy Named Barry",
"M Postkasse",
"AELOX",
"Gooohokrbe",
"Nicfit23",
"wamekukyouzin",
"OldBones",
"Jacob Hoehler",
"drum matthieu",
"Dogmaster",
"Matt Wenzel",
"Lex Song",
"Cory Paza",
"Christopher Michel",
"Gonzalo Andre Allendes Lopez",
"Zach Gonser",
"Serge Bekenkamp",
"Jimmy Ledbetter",
"Philip Hempel",
"LeoZero",
"Antonio Pontes",
"nahinahi9",
"Dustin Chen",
"dan",
"aai",
"Mouthlessman",
"otaku fra",
"jean jahren",
"MiraiKuriyamaSy",
"Ran C",
"ViperC",
"Penfore",
"Karl P.",
"Gordon Cole",
"Adam Taylor",
"AbstractAss",
"Weird_With_A_Beard",
"The Spawn",
"graysock",
"Pozadine1",
"Qarob",
"AIGooner",
"Luc",
"ProtonPrince",
"DiffDuck",
"Jackthemind",
"fancypants",
"Joboshy",
"Digital",
"takyamtom",
"Bohemian Corporal",
"Dan",
"Bro Xie",
"yer fey",
"batblue",
"carey6409",
"太郎 ゲーム",
"Roslynd",
"jinxedx",
"Neco28",
"Cosmosis",
"David Ortega",
"AELOX",
"Dankin",
"Nicfit23",
"FloPro4Sho",
"Cristian Vazquez",
"drum matthieu",
"Frank Nitty",
"Magic Noob",
"Christopher Michel",
"DougPeterson",
"LeoZero",
"Antonio Pontes",
"ApathyJones",
"Bruce",
"Julian V",
"Steven Owens",
"nahinahi9",
"Kevin John Duck",
"Dustin Chen",
"Blackfish95",
"Paul Kroll",
"Bas Imagineer",
"John Statham",
"yuxz69",
"esthe",
"decoy",
"elu3199",
"Hasturkun",
"Jon Sandman",
"Ubivis",
"CloudValley",
"linnfrey",
"Jackthemind",
"griffin+dahlberg",
"Joboshy",
"takyamtom",
"Bohemian Corporal",
"Dan",
"yer fey",
"Error_Rule34_Not_found",
"Roslynd",
"jinxedx",
"Neco28",
"Cosmosis",
"David Ortega",
"Dankin",
"FloPro4Sho",
"Cristian Vazquez",
"Frank Nitty",
"Magic Noob",
"DougPeterson",
"ApathyJones",
"Jeff",
"Bruce",
"Steven Owens",
"Kevin John Duck",
"Kevin Christopher",
"Blackfish95",
"dd",
"Paul Kroll",
"Bas Imagineer",
"John Statham",
"yuxz69",
"esthe",
"AlexDuKaNa",
"decoy",
"thesoftwaredruid",
"wundershark",
"mr_dinosaur",
@@ -231,53 +236,11 @@
"Ray Wing",
"Ranzitho",
"Gus",
"地獄の禄",
"MJG",
"David LaVallee",
"linnfrey",
"ae",
"Tr4shP4nda",
"IamAyam",
"skaterb949",
"Brian M",
"Josef Lanzl",
"Nerezza",
"sanborondon",
"confiscated Zyra",
"Error_Rule34_Not_found",
"Taylor Funk",
"aezin",
"jcay015",
"Gerald Welly",
"Erik Lopez",
"Mateo Curić",
"Tee Gee",
"Geolog",
"tarek helmi",
"Eris3D",
"Max Marklund",
"Pronredn",
"Jamie Ogletree",
"a _",
"Jeff",
"lh qwe",
"James Coleman",
"conner",
"Kevin Christopher",
"Chad Idk",
"dd",
"Princess Bright Eyes",
"Dušan Ryban",
"Felipe dos Santos",
"Sam",
"sjon kreutz",
"Douglas Gaspar",
"Metryman55",
"AlexDuKaNa",
"George",
"dw",
"地獄の禄",
"Gamalonia",
"WRL_SPR",
"capn",
"Joseph",
"Mirko Katzula",
@@ -285,6 +248,53 @@
"Piccio08",
"kumakichi",
"cppbel",
"IamAyam",
"jeaness",
"Brian M",
"Josef Lanzl",
"Nerezza",
"sanborondon",
"confiscated Zyra",
"Taylor Funk",
"aezin",
"Thought2Form",
"jcay015",
"Gerald Welly",
"Kevin Picco",
"Erik Lopez",
"Mateo Curić",
"Tee Gee",
"Geolog",
"tarek helmi",
"Eris3D",
"Max Marklund",
"m",
"Pierce McBride",
"Pronredn",
"Mikko Hemilä",
"Jamie Ogletree",
"a _",
"lh qwe",
"James Coleman",
"Martial",
"conner",
"Ouro Boros",
"Chad Idk",
"Princess Bright Eyes",
"Yuji Kaneko",
"Dušan Ryban",
"Felipe dos Santos",
"Rops Alot",
"Sam",
"sjon kreutz",
"Ace Ventura",
"Douglas Gaspar",
"Metryman55",
"George",
"dw",
"Gamalonia",
"WRL_SPR",
"momokai",
"Moon Knight",
"몽타주",
"Kland",
@@ -294,56 +304,9 @@
"ken",
"epicgamer0020690",
"Joshua Porrata",
"keemun",
"SuBu",
"RedPIXel",
"Richard",
"奚明 刘",
"Andrew",
"Robert Wegemund",
"Littlehuggy",
"준희 김",
"Brian Buie",
"Thought2Form",
"Kevin Picco",
"Sadlip",
"Joey Callahan",
"Tomohiro Baba",
"m",
"Noora",
"Pierce McBride",
"Joshua Gray",
"Mattssn",
"Mikko Hemilä",
"Jacob McDaniel",
"Temikus",
"Artokun",
"Michael Taylor",
"Derek Baker",
"Martial",
"Michael Anthony Scott",
"Emil Andersson",
"Ouro Boros",
"Atilla Berke Pekduyar",
"Steam Steam",
"CryptoTraderJK",
"Decx _",
"Yuji Kaneko",
"Davaitamin",
"Rops Alot",
"tedcor",
"Fotek Design",
"Ace Ventura",
"四糸凜音",
"Nihongasuki",
"LarsesFPC",
"MadSpin",
"inbijiburu",
"Nick “Loadstone” D",
"momokai",
"starbugx",
"dc7431",
"Crocket",
"keemun",
"Wind",
"Nexus",
"Ramneek“Guy”Ashok",
@@ -355,6 +318,51 @@
"JohnDoe42054",
"BillyHill",
"emyth",
"gzmzmvp",
"Richard",
"奚明 刘",
"Andrew",
"Robert Wegemund",
"Littlehuggy",
"Gregory Kozhemiak",
"준희 김",
"Brian Buie",
"Sadlip",
"Eric Whitney",
"Joey Callahan",
"Ivan Tadic",
"Tomohiro Baba",
"Mike Simone",
"Noora",
"Joshua Gray",
"Mattssn",
"Morgandel",
"Jacob McDaniel",
"X",
"Sloan Steddy",
"Temikus",
"Artokun",
"Michael Taylor",
"Derek Baker",
"Michael Anthony Scott",
"Emil Andersson",
"Atilla Berke Pekduyar",
"Steam Steam",
"CryptoTraderJK",
"Decx _",
"Davaitamin",
"tedcor",
"Fotek Design",
"四糸凜音",
"Nihongasuki",
"LarsesFPC",
"MadSpin",
"FrxzenSnxw",
"inbijiburu",
"Nick “Loadstone” D",
"starbugx",
"dc7431",
"Crocket",
"chriphost",
"KitKatM",
"socrasteeze",
@@ -374,56 +382,64 @@
"Adam Rinehart",
"Pitpe11",
"TheD1rtyD03",
"gzmzmvp",
"Gregory Kozhemiak",
"moonpetal",
"g9p0o",
"TheHolySheep",
"Monte Won",
"SpringBootisTrash",
"carsten",
"ikok",
"Wolfe7D1",
"Draven T",
"mrjuan",
"Eric Whitney",
"elleshar666",
"ACTUALLY_the_Real_Willem_Dafoe",
"Aquatic Coffee",
"Ivan Tadic",
"Mike Simone",
"Kauffy",
"John J Linehan",
"ethanfel",
"Elliot E",
"Morgandel",
"Theerat Jiramate",
"Focuschannel",
"Edward Kennedy",
"Noah",
"X",
"Sloan Steddy",
"Vane Holzer",
"psytrax",
"hexxish",
"Anthony Faxlandez",
"battu",
"notedfakes",
"Nathan",
"NICHOLAS BAXLEY",
"Michael Scott",
"Pat Hen",
"Xeeosat",
"Saya",
"Ed Wang",
"Jordan Shaw",
"Wes Sims",
"g unit",
"Srdb",
"Filippo Ferrari",
"JC",
"Prompt Pirate",
"uwutismxd",
"FrxzenSnxw",
"zenobeus",
"ryoma",
"Whitepinetrader",
"Stryker",
"Ginnie",
"Raku",
"smart.edge5178",
"Menard",
"moonpetal",
"SomeDude",
"g9p0o",
"Pkrsky",
"TheHolySheep",
"nanana",
"raf8osz",
"Monte Won",
"SpringBootisTrash",
"carsten",
"ikok",
"FeralOpticsAI",
"Pavlaki",
"Doug+Rintoul",
"Noor",
"Yorunai",
"quantenmecha",
"Jason+Nash",
"DarkRoast",
@@ -437,38 +453,34 @@
"cocona",
"ElitaSSJ4",
"David Schenck",
"Wolfe7D1",
"blikkies",
"Chris",
"Time Valentine",
"elleshar666",
"Shock Shockor",
"ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч",
"Matt",
"Goldwaters",
"Kauffy",
"Zude",
"Frogmilk",
"SPJ",
"Kyler",
"Edward Kennedy",
"Bryan Rutkowski",
"Justin Blaylock",
"aRtFuL_DodGeR",
"Nick Kage",
"Vane Holzer",
"psytrax",
"Cyrus Fett",
"Xenon Xue",
"notedfakes",
"Edward Ten Eyck",
"Billy Gladky",
"Michael Scott",
"Probis",
"Solixer",
"Wes Sims",
"ItsGeneralButtNaked",
"Donor4115",
"jinksta187",
"Distortik",
"Filippo Ferrari",
"Manu Thetug",
"Karlanx",
"operationancut",
"Youguang",
"andrewzpong",
"BossGame",
@@ -478,11 +490,19 @@
"AIVORY3D",
"Kevinj",
"Mitchell Robson",
"Whitepinetrader",
"POPPIN",
"nanana",
"YassineKhaled",
"Y",
"MatteKey",
"Flob",
"ShiroSenpai",
"Inkognito",
"G",
"Tan+Huynh",
"Bob+Barker",
"D",
"Dark_Pest",
"Eldithor",
"Alex",
"Karru",
"ChaChanoKo",
@@ -495,40 +515,36 @@
"g",
"J",
"Alan+Cano",
"FeralOpticsAI",
"Pavlaki",
"Doug+Rintoul",
"Noor",
"Yorunai",
"BillyBoy84",
"Buecyb99",
"Welkor",
"John Martin",
"Ink Temptation",
"JBsuede",
"moranqianlong",
"Kalli Core",
"Ronan Delevacq",
"Christian Schäfer",
"りん あめ",
"Dave Abraham",
"Joaquin Hierrezuelo",
"Locrospiel",
"Frogmilk",
"Sean voets",
"Jarrid Lee",
"Kor",
"Joseph Hanson",
"John Rednoulf",
"Kyron Mahan",
"Bryan Rutkowski",
"Boba Smith",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Ezokewn",
"SendingRavens",
"Sauv",
"Steven",
"JackJohnnyJim",
"TenaciousD",
"Dmitry Ryzhov",
"Khánh Đặng",
"Edward Ten Eyck",
"Michael Docherty",
"Jimmy Borup",
"Paul Hartsuyker",
@@ -536,16 +552,12 @@
"Pete Pain",
"Jacob Winter",
"Ryan Presley Ng",
"jinksta187",
"RHopkirk",
"Andrew Wilkinson",
"Manu Thetug",
"Karlanx",
"Lyavph",
"Maxim",
"David",
"Meilo",
"operationancut",
"shinonomeiro",
"Snille",
"MaartenAlbers",
@@ -564,6 +576,17 @@
"Scott",
"Muratoraccio",
"D",
"Akkas+Haque",
"Kachac",
"SAVEagleBasement",
"Kevin+Isom",
"Rune+Osnes",
"you+halo9",
"cloudghost",
"Yongkwan+Lee",
"PoorStudent",
"lucites",
"Alex+Zaw",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
@@ -584,57 +607,51 @@
"Lev+Lanevskiy",
"low9",
"Winged",
"YassineKhaled",
"Y",
"MatteKey",
"Flob",
"ShiroSenpai",
"Inkognito",
"G",
"Tan+Huynh",
"Jacky+Ho",
"generic404",
"abattoirblues",
"zounik",
"4IXplr0r3r",
"hayden",
"Obsidian.Studios",
"ahoystan",
"Bob Barker",
"Zomba Mann",
"edk",
"Tú Nguyễn Lý Hoàng",
"shira1011",
"Neko Desco",
"Ben D",
"G",
"Ronan Delevacq",
"Vinarus",
"ja s",
"Leslie Andrew Ridings",
"Doug Mason",
"Jeremy Townsend",
"Dave Abraham",
"scoreswazey",
"Owen Gwosdz",
"Jarrid Lee",
"Poophead27 Blyat",
"Spire",
"Mythspire",
"AZ Party Oasis",
"Boba Smith",
"Devil Lude",
"David Murcko",
"TheFusion",
"MR.Bear",
"Jack Dole",
"matt",
"somethingtosay8",
"3zS4QNQ4",
"Terminuz",
"ivistorm",
"max blo",
"Sauv",
"Ivan Imes",
"CptNeo",
"Jack Lawfield",
"Borte",
"Maso",
"Ted Cart",
"Sage Himeros",
"Eric Ketchum",
"Kevin Wallace",
"David Spearing",
"Zeeble",
"ChicRic",
"Tigon",
"BastardSama",
@@ -642,6 +659,7 @@
"SkibidiRizzler",
"Tania Nayelli Fernandez",
"Draconach",
"Kalle Björk",
"Yavizu3d",
"Yves Poezevara",
"Teriak47",
@@ -694,6 +712,13 @@
"SelfishMedic",
"adderleighn",
"EnragedAntelope",
"Somebody",
"Jasper",
"megameganck",
"thomasand01",
"Shiba+Sama",
"miduzza",
"KB",
"shw",
"Celestial+Kitten",
"bakeliteboy",
@@ -714,23 +739,12 @@
"matter",
"SRCRCOSS",
"imer",
"Akkas+Haque",
"Kachac",
"tyrant2811",
"Kevin",
"Rune+Osnes",
"jcx29",
"cloudghost",
"Yongkwan+Lee",
"PoorStudent",
"lucites",
"Alex+Zaw",
"Drizzly",
"Nebuleux",
"Join+Chun",
"GDS+DEV",
"4rt+r3d",
"you+halo9",
"Somebody",
"Somebody",
"Crescent~San",
@@ -743,35 +757,36 @@
"Bula",
"KUJYAKU",
"Coeur+de+cochon",
"Obsidian.Studios",
"han b",
"Zomba Mann",
"Aquaneo",
"Nico",
"Maximilian Krischan",
"Banana Joe",
"proto merp",
"_ G3n",
"Brandon Thomas",
"Donovan Jenkins",
"Hans Meier",
"sicarius",
"Michael Eid",
"Wolf and Fox Legends",
"beersandbacon",
"Neko Desco",
"Bob barker",
"Ninja Tom",
"karim ben brik",
"Vinarus",
"Elemnt",
"Josh Snyder",
"Michael Zhu",
"Nemisu",
"Seraphy",
"雨の心 落",
"AllTimeNoobie",
"swra",
"JollRodrigo",
"jumpd",
"John C",
"Rim",
"Oliverfish",
"yfx507",
"Room Light",
"Jairus Knudsen",
@@ -783,22 +798,20 @@
"Forbidden Atelier",
"Thomas Sankowski",
"DrB",
"Nimhloth",
"Adictedtohumping",
"Snorklebort",
"vinter",
"Towelie",
"TheFusion",
"Jean-françois SEMA",
"3zS4QNQ4",
"Kurt",
"Andrew Ly",
"Matt M.",
"Ivan Imes",
"J M",
"Slacks",
"Bouya shaka",
"john Greene",
"Faburizu",
"Jack Lawfield",
"jimyjomson",
"JaeHyun Jang",
"Homero Banda",
@@ -807,7 +820,7 @@
"yyuvuvu",
"Inyoshu",
"Chad Barnes",
"Person Y",
"Adam Gardner",
"Nomki",
"inusanorthcape",
"James Ming",
@@ -829,5 +842,5 @@
"Somebody",
"CK"
],
"totalCount": 826
"totalCount": 839
}
+208
View File
@@ -0,0 +1,208 @@
# Agent Skills System
The LoRA Manager agent skills system enables LLM-powered metadata enrichment and other AI-driven tasks. Users configure their own LLM provider (BYOK), and skills are executed through right-click context menu actions.
## Architecture
```
┌──────────────────────────────────────────────┐
│ LoRA Manager Backend │
│ │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ LLMService │───▶│ LLM Provider │ │
│ │ (BYOK config, │◀───│ (OpenAI/Ollama │ │
│ │ API calls) │ │ /custom) │ │
│ └───────┬───────┘ └────────────────┘ │
│ │ │
│ ┌───────▼───────────────────────┐ │
│ │ AgentService │ │
│ │ (orchestration: validate │ │
│ │ → LLM call → post-process │ │
│ │ → WebSocket broadcast) │ │
│ └───────┬───────────────────────┘ │
│ │ │
│ ┌───────▼───────────────────────┐ │
│ │ SkillRegistry │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ enrich_hf_metadata: │ │ │
│ │ │ - skill.yaml │ │ │
│ │ │ - prompt.md │ │ │
│ │ │ - handler.py │ │ │
│ │ └─────────────────────────┘ │ │
│ └───────────────────────────────┘ │
└──────────────────────────────────────────────┘
```
### Key Design Principle
**Skills define *what* to do (prompt + post-processing). The AgentService handles *how* (LLM calls, validation, progress).**
Skills never call the LLM directly. This keeps BYOK configuration centralized and provider-agnostic.
## BYOK Configuration
Users configure their LLM provider in **Settings → AI Provider**:
| Setting | Description | Example |
|---|---|---|
| `llm_provider` | Provider type | `openai`, `ollama`, or `custom` |
| `llm_api_key` | API key (not needed for local Ollama) | `sk-...` |
| `llm_api_base` | Custom API base URL (empty = provider default) | `https://api.openai.com/v1` |
| `llm_model` | Model name | `gpt-4o-mini` |
Environment variable overrides: `LLM_API_KEY`, `LLM_MODEL`, `LLM_API_BASE`, `LLM_PROVIDER`.
### Supported Providers
- **OpenAI**: Uses `https://api.openai.com/v1` by default
- **Ollama** (local): Uses `http://localhost:11434/v1`, no API key required
- **Custom**: Any OpenAI-compatible endpoint (vLLM, LM Studio, etc.) — set `llm_api_base` explicitly
## Available Skills
### enrich_hf_metadata
Enriches HuggingFace-downloaded models with metadata extracted by an LLM from the HF model card.
**Entry point**: Right-click context menu → "Enrich Metadata (Agent)"
**What it does**:
1. Reads the model's `.metadata.json` to get the `hf_url`
2. Fetches the README.md from the HuggingFace repository
3. Sends the README + local metadata to the LLM for structured extraction
4. Writes extracted fields to `.metadata.json`:
- `base_model` — only if current value is empty
- `trainedWords` — trigger words (LoRA only, if none exist)
- `modelDescription` — concise summary (if none exists)
- `tags` — merged with existing tags, deduplicated
- `metadata_source` — audit trail: `agent:enrich_hf_metadata`
- `llm_enriched_at` — ISO timestamp
5. Downloads and optimizes preview image (if LLM found one in the README)
6. Updates the scanner cache
7. Broadcasts WebSocket progress events
**Model types**: LoRA, Checkpoint, Embedding
## Adding a New Skill
### 1. Create the skill directory
```
py/services/agent/skills/<skill_name>/
├── skill.yaml # Skill metadata and schemas
├── prompt.md # LLM prompt template
└── handler.py # Pre-processing and post-processing
```
### 2. Write skill.yaml
```yaml
name: my_skill
title: "My Skill"
description: "What this skill does"
llm_required: true
model_type_filter: ["lora"] # or null for all types
input_schema:
type: object
properties:
model_paths:
type: array
items:
type: string
required:
- model_paths
output_schema:
type: object
properties:
# ... JSON schema for LLM output
permissions:
write_metadata: true
write_previews: false
network_domains:
- "example.com"
```
### 3. Write prompt.md
Use `{{variable}}` placeholders that will be replaced with data from the `prepare` function:
```markdown
You are an expert assistant...
Model URL: {{hf_url}}
README content:
{{readme_content}}
Current metadata:
{{current_metadata}}
```
### 4. Write handler.py
```python
async def prepare(model_path: str, input_data: dict) -> dict:
"""Gather context for the LLM prompt. Returns variables for template rendering."""
return {
"model_path": model_path,
# ... other variables used in prompt.md
}
async def post_process(context) -> dict:
"""Apply the LLM-extracted data to the model."""
llm_response = context.llm_response
# ... write metadata, download previews, update cache
return {
"success": True,
"updated_fields": ["base_model", "tags"],
"errors": [],
}
```
**Important**: Use absolute imports (`from py.utils.metadata_manager import MetadataManager`) because skills are loaded via `importlib.util.spec_from_file_location`, which doesn't support relative imports.
### 5. Test
The skill is automatically discovered by `SkillRegistry` on startup. Test with:
```python
pytest tests/services/test_agent_service.py
```
## API Endpoints
| Method | Path | Description |
|---|---|---|
| GET | `/api/lm/agent/skills` | List available skills |
| POST | `/api/lm/agent/execute/{skill_name}` | Execute a skill (body: `{"model_paths": [...]}`) |
| POST | `/api/lm/agent/cancel` | Cancel running skill (stub) |
## WebSocket Events
| Type | When | Key fields |
|---|---|---|
| `agent_progress` | Skill started/processing | `skill`, `status`, `total`, `processed`, `success`, `current_path` |
| `agent_progress` | Skill completed | `skill`, `status`, `updated_models`, `errors`, `summary` |
| `agent_progress` | Skill error | `skill`, `status`, `error` |
## Security Model
Skills declare permissions in `skill.yaml`:
- `write_metadata` — can write `.metadata.json` files
- `write_previews` — can download/replace preview images
- `network_domains` — allowed domains for HTTP requests
These are declarative constraints checked by `AgentService`. They are defense-in-depth, not a sandbox — the Python process can technically do anything, but the contract is clear and auditable.
## File Locations
| Component | Path |
|---|---|
| LLMService | `py/services/llm_service.py` |
| AgentService | `py/services/agent/agent_service.py` |
| SkillRegistry | `py/services/agent/skill_registry.py` |
| SkillDefinition | `py/services/agent/skill_definition.py` |
| Skills directory | `py/services/agent/skills/` |
| Route handlers | `py/routes/handlers/agent_handlers.py` |
| Frontend manager | `static/js/managers/AgentManager.js` |
| Settings UI | `templates/components/modals/settings_modal.html` |
| Context menu | `templates/components/context_menu.html` |
+63 -16
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "Aus Favoriten entfernen",
"viewOnCivitai": "Auf Civitai anzeigen",
"notAvailableFromCivitai": "Nicht auf Civitai verfügbar",
"viewOnHuggingFace": "Auf Hugging Face ansehen",
"sendToWorkflow": "An ComfyUI senden (Klick: Anhängen, Shift+Klick: Ersetzen)",
"copyLoRASyntax": "LoRA-Syntax kopieren",
"checkpointNameCopied": "Checkpoint-Name kopiert",
@@ -145,6 +146,10 @@
},
"usage": {
"timesUsed": "Verwendungsanzahl"
},
"footer": {
"versionCount": "{count} Versionen",
"viewAllVersions": "Alle lokalen Versionen anzeigen"
}
},
"globalContextMenu": {
@@ -183,6 +188,9 @@
},
"manageExcludedModels": {
"label": "Ausgeschlossene Modelle verwalten"
},
"groupByModel": {
"label": "Nach Modell gruppieren"
}
},
"header": {
@@ -195,13 +203,7 @@
"statistics": "Statistiken"
},
"search": {
"placeholder": "Suchen...",
"placeholders": {
"loras": "LoRAs suchen...",
"recipes": "Rezepte suchen...",
"checkpoints": "Checkpoints suchen...",
"embeddings": "Embeddings suchen..."
},
"placeholder": "Suchen",
"options": "Suchoptionen",
"searchIn": "Suchen in:",
"notAvailable": "Suche auf Statistikseite nicht verfügbar",
@@ -325,7 +327,7 @@
"extraFolderPaths": "Zusätzliche Ordnerpfade",
"downloadPathTemplates": "Download-Pfad-Vorlagen",
"priorityTags": "Prioritäts-Tags",
"updateFlags": "Update-Markierungen",
"versionScope": "Update-Markierungen",
"exampleImages": "Beispielbilder",
"autoOrganize": "Auto-Organisierung",
"metadata": "Metadaten",
@@ -430,6 +432,8 @@
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
},
"layoutSettings": {
"groupByModel": "Nach Modell gruppieren",
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
"displayDensity": "Anzeige-Dichte",
"displayDensityOptions": {
"default": "Standard",
@@ -586,7 +590,7 @@
"download": "Herunterladen",
"restartRequired": "Neustart erforderlich"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "Strategie für Update-Markierungen",
"help": "Entscheide, ob Update-Badges nur dann erscheinen, wenn eine neue Version dasselbe Basismodell wie deine lokalen Dateien verwendet, oder sobald es irgendein neueres Release für dieses Modell gibt.",
"options": {
@@ -653,6 +657,23 @@
"proxyPassword": "Passwort (optional)",
"proxyPasswordPlaceholder": "passwort",
"proxyPasswordHelp": "Passwort für die Proxy-Authentifizierung (falls erforderlich)"
},
"aiProvider": {
"title": "KI-Anbieter",
"provider": "Anbieter",
"providerHelp": "Wählen Sie Ihren LLM-Anbieter. OpenAI und Ollama verwenden voreingestellte API-Endpunkte. Mit \"Benutzerdefiniert\" können Sie jeden OpenAI-kompatiblen Endpunkt angeben.",
"custom": "Benutzerdefiniert (OpenAI-kompatibel)",
"apiBase": "API-Basis-URL",
"apiBaseHelp": "Die Basis-URL für die LLM-API (z.B. https://api.openai.com/v1). Leer lassen, um die Anbietervoreinstellung zu verwenden.",
"apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "API-Schlüssel",
"apiKeyHelp": "Ihr LLM-API-Schlüssel. Wird lokal gespeichert und niemals an einen anderen Server außer Ihrem gewählten LLM-Anbieter gesendet.",
"apiKeyPlaceholder": "sk-...",
"apiKeyNotSet": "Nicht festgelegt",
"apiKeyConfigured": "Konfiguriert",
"apiKeySet": "Einrichten",
"model": "Modell",
"modelHelp": "Der zu verwendende Modellname (z.B. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Prüfen Sie Ihren Anbieter auf verfügbare Modelle."
}
},
"loras": {
@@ -670,7 +691,11 @@
"sizeAsc": "Kleinste",
"usage": "Anzahl Nutzung",
"usageDesc": "Meiste",
"usageAsc": "Wenigste"
"usageAsc": "Wenigste",
"versionsCount": "Lokale Versionen",
"versionsCountDesc": "Meiste Versionen zuerst",
"versionsCountAsc": "Wenigste Versionen zuerst",
"versionIdDesc": "Neueste Version zuerst"
},
"refresh": {
"title": "Modelliste aktualisieren",
@@ -746,7 +771,8 @@
"completed": "Abgeschlossen: {success} verschoben, {skipped} übersprungen, {failures} fehlgeschlagen",
"complete": "Automatische Organisation abgeschlossen",
"error": "Fehler: {error}"
}
},
"enrichHfAgent": "Metadaten mit KI anreichern"
},
"contextMenu": {
"refreshMetadata": "Civitai-Daten aktualisieren",
@@ -770,7 +796,8 @@
"shareRecipe": "Rezept teilen",
"viewAllLoras": "Alle LoRAs anzeigen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
"deleteRecipe": "Rezept löschen"
"deleteRecipe": "Rezept löschen",
"enrichHfAgent": "Metadaten mit KI anreichern"
}
},
"recipes": {
@@ -1127,7 +1154,10 @@
"titleWithType": "{type} von URL herunterladen",
"civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Geben Sie eine CivitAI- oder CivArchive-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
"urlHint": "Geben Sie eine CivitAI-, CivArchive- oder Hugging Face-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
"selectHfFiles": "Datei(en) zum Herunterladen aus diesem Repository auswählen:",
"selectAll": "Alle auswählen",
"fetchingRepoFiles": "Repository-Dateien werden abgerufen...",
"locationPreview": "Download-Speicherort Vorschau",
"useDefaultPath": "Standardpfad verwenden",
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
@@ -1156,7 +1186,9 @@
},
"errors": {
"invalidUrl": "Ungültiges Civitai URL-Format",
"noVersions": "Keine Versionen für dieses Modell verfügbar"
"noVersions": "Keine Versionen für dieses Modell verfügbar",
"mixedSources": "CivitAI- und Hugging Face-URLs können nicht in derselben Charge gemischt werden.",
"noModelFiles": "In diesem Repository wurden keine Modelldateien gefunden."
},
"status": {
"preparing": "Download wird vorbereitet...",
@@ -1307,6 +1339,8 @@
"editVersionName": "Versionsname bearbeiten",
"viewOnCivitai": "Auf Civitai anzeigen",
"viewOnCivitaiText": "Auf Civitai anzeigen",
"viewOnHuggingFace": "Auf Hugging Face ansehen",
"viewOnHuggingFaceText": "Auf Hugging Face ansehen",
"viewCreatorProfile": "Ersteller-Profil anzeigen",
"openFileLocation": "Dateispeicherort öffnen",
"sendToWorkflow": "An ComfyUI senden",
@@ -1332,7 +1366,10 @@
"additionalNotes": "Zusätzliche Notizen",
"notesHint": "Enter zum Speichern, Shift+Enter für neue Zeile",
"addNotesPlaceholder": "Fügen Sie hier Ihre Notizen hinzu...",
"aboutThisVersion": "Über diese Version"
"aboutThisVersion": "Über diese Version",
"baseModelSearchPlaceholder": "Basismodell suchen…",
"baseModelSuggested": "Vorschlag",
"baseModelNoMatch": "Keine passenden Basismodelle"
},
"notes": {
"saved": "Notizen erfolgreich gespeichert",
@@ -1608,12 +1645,15 @@
"modelUpdated": "Modell im Workflow aktualisiert",
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
"embeddingAdded": "Embedding zum Workflow hinzugefügt",
"embeddingFailed": "Fehler beim Hinzufügen des Embeddings"
"embeddingFailed": "Fehler beim Hinzufügen des Embeddings",
"promptSent": "Prompt an Workflow gesendet",
"promptFailed": "Fehler beim Senden des Prompts"
},
"nodeSelector": {
"recipe": "Rezept",
"lora": "LoRA",
"embedding": "Embedding",
"prompt": "Prompt",
"replace": "Ersetzen",
"append": "Anhängen",
"selectTargetNode": "Zielknoten auswählen",
@@ -1800,6 +1840,7 @@
"enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein",
"reconnectedSuccessfully": "LoRA erfolgreich neu verbunden",
"reconnectFailed": "Fehler beim Neuverbinden des LoRA: {message}",
"noPromptToSend": "Kein zu sendender Prompt",
"cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID",
"sendFailed": "Fehler beim Senden des Rezepts an Workflow",
"sendError": "Fehler beim Senden des Rezepts an Workflow",
@@ -2059,6 +2100,12 @@
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "In die Zwischenablage kopiert",
"downloadStarted": "Download gestartet"
},
"agent": {
"llmNotConfigured": "KI-Anbieter nicht konfiguriert. Aktivieren Sie ihn unter Einstellungen → KI-Anbieter.",
"enrichStarted": "Metadaten werden mit KI angereichert...",
"enrichComplete": "Metadatenanreicherung abgeschlossen: {{summary}}",
"enrichFailed": "Metadatenanreicherung fehlgeschlagen: {{error}}"
}
},
"doctor": {
+2169 -2122
View File
File diff suppressed because it is too large Load Diff
+63 -16
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "Eliminar de favoritos",
"viewOnCivitai": "Ver en Civitai",
"notAvailableFromCivitai": "No disponible en Civitai",
"viewOnHuggingFace": "Ver en Hugging Face",
"sendToWorkflow": "Enviar a ComfyUI (Clic: Añadir, Shift+Clic: Reemplazar)",
"copyLoRASyntax": "Copiar sintaxis de LoRA",
"checkpointNameCopied": "Nombre del checkpoint copiado",
@@ -145,6 +146,10 @@
},
"usage": {
"timesUsed": "Veces usado"
},
"footer": {
"versionCount": "{count} versiones",
"viewAllVersions": "Ver todas las versiones locales"
}
},
"globalContextMenu": {
@@ -183,6 +188,9 @@
},
"manageExcludedModels": {
"label": "Gestionar modelos excluidos"
},
"groupByModel": {
"label": "Agrupar por modelo"
}
},
"header": {
@@ -195,13 +203,7 @@
"statistics": "Estadísticas"
},
"search": {
"placeholder": "Buscar...",
"placeholders": {
"loras": "Buscar LoRAs...",
"recipes": "Buscar recetas...",
"checkpoints": "Buscar checkpoints...",
"embeddings": "Buscar embeddings..."
},
"placeholder": "Buscar",
"options": "Opciones de búsqueda",
"searchIn": "Buscar en:",
"notAvailable": "Búsqueda no disponible en la página de estadísticas",
@@ -325,7 +327,7 @@
"extraFolderPaths": "Rutas de carpetas adicionales",
"downloadPathTemplates": "Plantillas de rutas de descarga",
"priorityTags": "Etiquetas prioritarias",
"updateFlags": "Indicadores de actualización",
"versionScope": "Indicadores de actualización",
"exampleImages": "Imágenes de ejemplo",
"autoOrganize": "Organización automática",
"metadata": "Metadatos",
@@ -430,6 +432,8 @@
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
},
"layoutSettings": {
"groupByModel": "Agrupar por modelo",
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de Civitai como una tarjeta única. Las versiones anteriores están ocultas.",
"displayDensity": "Densidad de visualización",
"displayDensityOptions": {
"default": "Predeterminado",
@@ -586,7 +590,7 @@
"download": "Descargar",
"restartRequired": "Requiere reinicio"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "Estrategia de indicadores de actualización",
"help": "Decide si las insignias de actualización deben mostrarse solo cuando una nueva versión comparte el mismo modelo base que tus archivos locales o siempre que exista cualquier versión más reciente de ese modelo.",
"options": {
@@ -653,6 +657,23 @@
"proxyPassword": "Contraseña (opcional)",
"proxyPasswordPlaceholder": "contraseña",
"proxyPasswordHelp": "Contraseña para autenticación de proxy (si es necesario)"
},
"aiProvider": {
"title": "Proveedor de IA",
"provider": "Proveedor",
"providerHelp": "Elija su proveedor de LLM. OpenAI y Ollama usan endpoints predefinidos. Personalizado le permite especificar cualquier endpoint compatible con OpenAI.",
"custom": "Personalizado (compatible con OpenAI)",
"apiBase": "URL base de la API",
"apiBaseHelp": "La URL base para la API LLM (p.ej. https://api.openai.com/v1). Déjelo vacío para usar el valor predeterminado del proveedor.",
"apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "Clave de API",
"apiKeyHelp": "Su clave de API del proveedor LLM. Se almacena localmente y nunca se envía a ningún servidor excepto a su proveedor LLM elegido.",
"apiKeyPlaceholder": "sk-...",
"apiKeyNotSet": "No configurada",
"apiKeyConfigured": "Configurada",
"apiKeySet": "Configurar",
"model": "Modelo",
"modelHelp": "El nombre del modelo a usar (p.ej. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Consulte a su proveedor para ver los modelos disponibles."
}
},
"loras": {
@@ -670,7 +691,11 @@
"sizeAsc": "Menor",
"usage": "Número de usos",
"usageDesc": "Más",
"usageAsc": "Menos"
"usageAsc": "Menos",
"versionsCount": "Versiones locales",
"versionsCountDesc": "Más versiones primero",
"versionsCountAsc": "Menos versiones primero",
"versionIdDesc": "Versión más nueva primero"
},
"refresh": {
"title": "Actualizar lista de modelos",
@@ -746,7 +771,8 @@
"completed": "Completado: {success} movidos, {skipped} omitidos, {failures} fallidos",
"complete": "Auto-organización completada",
"error": "Error: {error}"
}
},
"enrichHfAgent": "Enriquecer metadatos (IA)"
},
"contextMenu": {
"refreshMetadata": "Actualizar datos de Civitai",
@@ -770,7 +796,8 @@
"shareRecipe": "Compartir receta",
"viewAllLoras": "Ver todos los LoRAs",
"downloadMissingLoras": "Descargar LoRAs faltantes",
"deleteRecipe": "Eliminar receta"
"deleteRecipe": "Eliminar receta",
"enrichHfAgent": "Enriquecer metadatos (IA)"
}
},
"recipes": {
@@ -1127,7 +1154,10 @@
"titleWithType": "Descargar {type} desde URL",
"civitaiUrl": "URL de Civitai:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Ingrese una URL de CivitAI o CivArchive por línea. Admite múltiples URLs para descarga por lotes.",
"urlHint": "Ingrese una URL de CivitAI, CivArchive o Hugging Face por línea. Admite múltiples URLs para descarga por lotes.",
"selectHfFiles": "Seleccione el/los archivo(s) para descargar de este repositorio:",
"selectAll": "Seleccionar todo",
"fetchingRepoFiles": "Obteniendo archivos del repositorio...",
"locationPreview": "Vista previa de ubicación de descarga",
"useDefaultPath": "Usar ruta predeterminada",
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
@@ -1156,7 +1186,9 @@
},
"errors": {
"invalidUrl": "Formato de URL de Civitai inválido",
"noVersions": "No hay versiones disponibles para este modelo"
"noVersions": "No hay versiones disponibles para este modelo",
"mixedSources": "No se pueden mezclar URL de CivitAI y Hugging Face en el mismo lote.",
"noModelFiles": "No se encontraron archivos de modelo en este repositorio."
},
"status": {
"preparing": "Preparando descarga...",
@@ -1307,6 +1339,8 @@
"editVersionName": "Editar nombre de versión",
"viewOnCivitai": "Ver en Civitai",
"viewOnCivitaiText": "Ver en Civitai",
"viewOnHuggingFace": "Ver en Hugging Face",
"viewOnHuggingFaceText": "Ver en Hugging Face",
"viewCreatorProfile": "Ver perfil del creador",
"openFileLocation": "Abrir ubicación del archivo",
"sendToWorkflow": "Enviar a ComfyUI",
@@ -1332,7 +1366,10 @@
"additionalNotes": "Notas adicionales",
"notesHint": "Presiona Enter para guardar, Shift+Enter para nueva línea",
"addNotesPlaceholder": "Añade tus notas aquí...",
"aboutThisVersion": "Acerca de esta versión"
"aboutThisVersion": "Acerca de esta versión",
"baseModelSearchPlaceholder": "Buscar modelo base…",
"baseModelSuggested": "Sugerido",
"baseModelNoMatch": "No hay modelos base que coincidan"
},
"notes": {
"saved": "Notas guardadas exitosamente",
@@ -1608,12 +1645,15 @@
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
"modelFailed": "Error al actualizar nodo de modelo",
"embeddingAdded": "Embedding añadido al flujo de trabajo",
"embeddingFailed": "Error al añadir el embedding"
"embeddingFailed": "Error al añadir el embedding",
"promptSent": "Prompt enviado al flujo de trabajo",
"promptFailed": "Error al enviar el prompt"
},
"nodeSelector": {
"recipe": "Receta",
"lora": "LoRA",
"embedding": "Embedding",
"prompt": "Prompt",
"replace": "Reemplazar",
"append": "Añadir",
"selectTargetNode": "Seleccionar nodo de destino",
@@ -1800,6 +1840,7 @@
"enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis",
"reconnectedSuccessfully": "LoRA reconectado exitosamente",
"reconnectFailed": "Error reconectando LoRA: {message}",
"noPromptToSend": "No hay prompt para enviar",
"cannotSend": "No se puede enviar receta: Falta ID de receta",
"sendFailed": "Error al enviar receta al flujo de trabajo",
"sendError": "Error enviando receta al flujo de trabajo",
@@ -2059,6 +2100,12 @@
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Copiado al portapapeles",
"downloadStarted": "Descarga iniciada"
},
"agent": {
"llmNotConfigured": "Proveedor de IA no configurado. Actívelo en Configuración → Proveedor de IA.",
"enrichStarted": "Enriqueciendo metadatos con IA...",
"enrichComplete": "Enriquecimiento de metadatos completado: {{summary}}",
"enrichFailed": "Enriquecimiento de metadatos fallido: {{error}}"
}
},
"doctor": {
+63 -16
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "Retirer des favoris",
"viewOnCivitai": "Voir sur Civitai",
"notAvailableFromCivitai": "Non disponible sur Civitai",
"viewOnHuggingFace": "Voir sur Hugging Face",
"sendToWorkflow": "Envoyer vers ComfyUI (Clic: Ajouter, Maj+Clic: Remplacer)",
"copyLoRASyntax": "Copier la syntaxe LoRA",
"checkpointNameCopied": "Nom du checkpoint copié",
@@ -145,6 +146,10 @@
},
"usage": {
"timesUsed": "Nombre d'utilisations"
},
"footer": {
"versionCount": "{count} versions",
"viewAllVersions": "Voir toutes les versions locales"
}
},
"globalContextMenu": {
@@ -183,6 +188,9 @@
},
"manageExcludedModels": {
"label": "Gérer les modèles exclus"
},
"groupByModel": {
"label": "Grouper par modèle"
}
},
"header": {
@@ -195,13 +203,7 @@
"statistics": "Statistiques"
},
"search": {
"placeholder": "Rechercher...",
"placeholders": {
"loras": "Rechercher des LoRAs...",
"recipes": "Rechercher des recipes...",
"checkpoints": "Rechercher des checkpoints...",
"embeddings": "Rechercher des embeddings..."
},
"placeholder": "Rechercher",
"options": "Options de recherche",
"searchIn": "Rechercher dans :",
"notAvailable": "Recherche non disponible sur la page de statistiques",
@@ -325,7 +327,7 @@
"extraFolderPaths": "Chemins de dossiers supplémentaires",
"downloadPathTemplates": "Modèles de chemin de téléchargement",
"priorityTags": "Étiquettes prioritaires",
"updateFlags": "Indicateurs de mise à jour",
"versionScope": "Indicateurs de mise à jour",
"exampleImages": "Images d'exemple",
"autoOrganize": "Organisation automatique",
"metadata": "Métadonnées",
@@ -430,6 +432,8 @@
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
},
"layoutSettings": {
"groupByModel": "Grouper par modèle",
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle Civitai s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
"displayDensity": "Densité d'affichage",
"displayDensityOptions": {
"default": "Par défaut",
@@ -586,7 +590,7 @@
"download": "Télécharger",
"restartRequired": "Redémarrage requis"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "Stratégie des indicateurs de mise à jour",
"help": "Choisissez si les badges de mise à jour doivent apparaître uniquement lorsquune nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès quil existe une version plus récente pour ce modèle.",
"options": {
@@ -653,6 +657,23 @@
"proxyPassword": "Mot de passe (optionnel)",
"proxyPasswordPlaceholder": "mot_de_passe",
"proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)"
},
"aiProvider": {
"title": "Fournisseur d'IA",
"provider": "Fournisseur",
"providerHelp": "Choisissez votre fournisseur LLM. OpenAI et Ollama utilisent des endpoints prédéfinis. Personnalisé vous permet de spécifier n'importe quel endpoint compatible OpenAI.",
"custom": "Personnalisé (compatible OpenAI)",
"apiBase": "URL de base de l'API",
"apiBaseHelp": "L'URL de base pour l'API LLM (ex. https://api.openai.com/v1). Laissez vide pour utiliser le fournisseur par défaut.",
"apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "Clé API",
"apiKeyHelp": "Votre clé API du fournisseur LLM. Stockée localement, jamais envoyée à un serveur autre que votre fournisseur LLM choisi.",
"apiKeyPlaceholder": "sk-...",
"apiKeyNotSet": "Non définie",
"apiKeyConfigured": "Configurée",
"apiKeySet": "Configurer",
"model": "Modèle",
"modelHelp": "Le nom du modèle à utiliser (ex. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Consultez votre fournisseur pour les modèles disponibles."
}
},
"loras": {
@@ -670,7 +691,11 @@
"sizeAsc": "Plus petit",
"usage": "Nombre d'utilisations",
"usageDesc": "Plus",
"usageAsc": "Moins"
"usageAsc": "Moins",
"versionsCount": "Versions locales",
"versionsCountDesc": "Plus de versions d'abord",
"versionsCountAsc": "Moins de versions d'abord",
"versionIdDesc": "Version la plus récente d'abord"
},
"refresh": {
"title": "Actualiser la liste des modèles",
@@ -746,7 +771,8 @@
"completed": "Terminé : {success} déplacés, {skipped} ignorés, {failures} échecs",
"complete": "Auto-organisation terminée",
"error": "Erreur : {error}"
}
},
"enrichHfAgent": "Enrichir les métadonnées (IA)"
},
"contextMenu": {
"refreshMetadata": "Actualiser les données Civitai",
@@ -770,7 +796,8 @@
"shareRecipe": "Partager la recipe",
"viewAllLoras": "Voir tous les LoRAs",
"downloadMissingLoras": "Télécharger les LoRAs manquants",
"deleteRecipe": "Supprimer la recipe"
"deleteRecipe": "Supprimer la recipe",
"enrichHfAgent": "Enrichir les métadonnées (IA)"
}
},
"recipes": {
@@ -1127,7 +1154,10 @@
"titleWithType": "Télécharger {type} depuis une URL",
"civitaiUrl": "URL Civitai :",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Entrez une URL CivitAI ou CivArchive par ligne. Prend en charge plusieurs URLs pour le téléchargement par lot.",
"urlHint": "Entrez une URL CivitAI, CivArchive ou Hugging Face par ligne. Prend en charge plusieurs URL pour le téléchargement par lot.",
"selectHfFiles": "Sélectionnez le(s) fichier(s) à télécharger depuis ce dépôt :",
"selectAll": "Tout sélectionner",
"fetchingRepoFiles": "Récupération des fichiers du dépôt...",
"locationPreview": "Aperçu de l'emplacement de téléchargement",
"useDefaultPath": "Utiliser le chemin par défaut",
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
@@ -1156,7 +1186,9 @@
},
"errors": {
"invalidUrl": "Format d'URL Civitai invalide",
"noVersions": "Aucune version disponible pour ce modèle"
"noVersions": "Aucune version disponible pour ce modèle",
"mixedSources": "Impossible de mélanger les URL CivitAI et Hugging Face dans le même lot.",
"noModelFiles": "Aucun fichier de modèle trouvé dans ce dépôt."
},
"status": {
"preparing": "Préparation du téléchargement...",
@@ -1307,6 +1339,8 @@
"editVersionName": "Modifier le nom de la version",
"viewOnCivitai": "Voir sur Civitai",
"viewOnCivitaiText": "Voir sur Civitai",
"viewOnHuggingFace": "Voir sur Hugging Face",
"viewOnHuggingFaceText": "Voir sur Hugging Face",
"viewCreatorProfile": "Voir le profil du créateur",
"openFileLocation": "Ouvrir l'emplacement du fichier",
"sendToWorkflow": "Envoyer vers ComfyUI",
@@ -1332,7 +1366,10 @@
"additionalNotes": "Notes supplémentaires",
"notesHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne",
"addNotesPlaceholder": "Ajoutez vos notes ici...",
"aboutThisVersion": "À propos de cette version"
"aboutThisVersion": "À propos de cette version",
"baseModelSearchPlaceholder": "Rechercher un modèle de base…",
"baseModelSuggested": "Suggéré",
"baseModelNoMatch": "Aucun modèle de base correspondant"
},
"notes": {
"saved": "Notes sauvegardées avec succès",
@@ -1608,12 +1645,15 @@
"modelUpdated": "Modèle mis à jour dans le workflow",
"modelFailed": "Échec de la mise à jour du nœud modèle",
"embeddingAdded": "Embedding ajouté au workflow",
"embeddingFailed": "Échec de l'ajout de l'embedding"
"embeddingFailed": "Échec de l'ajout de l'embedding",
"promptSent": "Prompt envoyé au workflow",
"promptFailed": "Échec de l'envoi du prompt"
},
"nodeSelector": {
"recipe": "Recipe",
"lora": "LoRA",
"embedding": "Embedding",
"prompt": "Prompt",
"replace": "Remplacer",
"append": "Ajouter",
"selectTargetNode": "Sélectionner le nœud cible",
@@ -1800,6 +1840,7 @@
"enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA",
"reconnectedSuccessfully": "LoRA reconnecté avec succès",
"reconnectFailed": "Erreur lors de la reconnexion du LoRA : {message}",
"noPromptToSend": "Aucun prompt à envoyer",
"cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant",
"sendFailed": "Échec de l'envoi de la recipe vers le workflow",
"sendError": "Erreur lors de l'envoi de la recipe vers le workflow",
@@ -2059,6 +2100,12 @@
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Copié dans le presse-papiers",
"downloadStarted": "Téléchargement démarré"
},
"agent": {
"llmNotConfigured": "Fournisseur d'IA non configuré. Activez-le dans Paramètres → Fournisseur d'IA.",
"enrichStarted": "Enrichissement des métadonnées par IA...",
"enrichComplete": "Enrichissement des métadonnées terminé : {{summary}}",
"enrichFailed": "Échec de l'enrichissement des métadonnées : {{error}}"
}
},
"doctor": {
+63 -16
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "הסר מהמועדפים",
"viewOnCivitai": "הצג ב-Civitai",
"notAvailableFromCivitai": "לא זמין מ-Civitai",
"viewOnHuggingFace": "צפייה ב-Hugging Face",
"sendToWorkflow": "שלח ל-ComfyUI (לחיצה: הוסף, Shift+לחיצה: החלף)",
"copyLoRASyntax": "העתק תחביר LoRA",
"checkpointNameCopied": "שם Checkpoint הועתק",
@@ -145,6 +146,10 @@
},
"usage": {
"timesUsed": "מספר שימושים"
},
"footer": {
"versionCount": "{count} גרסאות",
"viewAllVersions": "הצג את כל הגרסאות המקומיות"
}
},
"globalContextMenu": {
@@ -183,6 +188,9 @@
},
"manageExcludedModels": {
"label": "ניהול מודלים מוחרגים"
},
"groupByModel": {
"label": "קיבוץ לפי דגם"
}
},
"header": {
@@ -195,13 +203,7 @@
"statistics": "סטטיסטיקה"
},
"search": {
"placeholder": פש...",
"placeholders": {
"loras": "חפש LoRAs...",
"recipes": "חפש מתכונים...",
"checkpoints": "חפש checkpoints...",
"embeddings": "חפש embeddings..."
},
"placeholder": יפוש",
"options": "אפשרויות חיפוש",
"searchIn": "חפש ב:",
"notAvailable": "חיפוש לא זמין בדף הסטטיסטיקה",
@@ -325,7 +327,7 @@
"extraFolderPaths": "נתיבי תיקיות נוספים",
"downloadPathTemplates": "תבניות נתיב הורדה",
"priorityTags": "תגיות עדיפות",
"updateFlags": "תגי עדכון",
"versionScope": "תגי עדכון",
"exampleImages": "תמונות דוגמה",
"autoOrganize": "ארגון אוטומטי",
"metadata": "מטא-נתונים",
@@ -430,6 +432,8 @@
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
},
"layoutSettings": {
"groupByModel": "קיבוץ לפי דגם",
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
"displayDensity": "צפיפות תצוגה",
"displayDensityOptions": {
"default": "ברירת מחדל",
@@ -586,7 +590,7 @@
"download": "הורד",
"restartRequired": "דורש הפעלה מחדש"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "אסטרטגיית תגי עדכון",
"help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.",
"options": {
@@ -653,6 +657,23 @@
"proxyPassword": "סיסמה (אופציונלי)",
"proxyPasswordPlaceholder": "password",
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
},
"aiProvider": {
"title": "ספק AI",
"provider": "ספק",
"providerHelp": "בחר את ספק ה-LLM שלך. OpenAI ו-Ollama משתמשים בנקודות קצה מוגדרות מראש. מותאם אישית מאפשר לך לציין כל נקודת קצה תואמת OpenAI.",
"custom": "מותאם אישית (תואם OpenAI)",
"apiBase": "כתובת בסיס API",
"apiBaseHelp": "כתובת ה-URL הבסיסית ל-API של LLM (לדוגמה https://api.openai.com/v1). השאר ריק לשימוש בברירת המחדל של הספק.",
"apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "מפתח API",
"apiKeyHelp": "מפתח ה-API של ספק ה-LLM שלך. נשמר מקומית, לעולם לא נשלח לשרת כלשהו מלבד ספק ה-LLM שבחרת.",
"apiKeyPlaceholder": "sk-...",
"apiKeyNotSet": "לא הוגדר",
"apiKeyConfigured": "הוגדר",
"apiKeySet": "הגדר",
"model": "מודל",
"modelHelp": "שם המודל לשימוש (לדוגמה deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). בדוק אצל הספק שלך אילו מודלים זמינים."
}
},
"loras": {
@@ -670,7 +691,11 @@
"sizeAsc": "הקטן ביותר",
"usage": "מספר שימושים",
"usageDesc": "הכי הרבה",
"usageAsc": "הכי פחות"
"usageAsc": "הכי פחות",
"versionsCount": "גרסאות מקומיות",
"versionsCountDesc": "הכי הרבה גרסאות ראשונות",
"versionsCountAsc": "הכי מעט גרסאות ראשונות",
"versionIdDesc": "גרסה חדשה ביותר ראשונה"
},
"refresh": {
"title": "רענן רשימת מודלים",
@@ -746,7 +771,8 @@
"completed": "הושלם: {success} הועברו, {skipped} דולגו, {failures} נכשלו",
"complete": "ארגון אוטומטי הושלם",
"error": "שגיאה: {error}"
}
},
"enrichHfAgent": "העשרת מטא-דאטה (AI)"
},
"contextMenu": {
"refreshMetadata": "רענן נתוני Civitai",
@@ -770,7 +796,8 @@
"shareRecipe": "שתף מתכון",
"viewAllLoras": "הצג את כל ה-LoRAs",
"downloadMissingLoras": "הורד LoRAs חסרים",
"deleteRecipe": "מחק מתכון"
"deleteRecipe": "מחק מתכון",
"enrichHfAgent": "העשרת מטא-דאטה (AI)"
}
},
"recipes": {
@@ -1127,7 +1154,10 @@
"titleWithType": "הורד {type} מכתובת URL",
"civitaiUrl": "כתובת URL של Civitai:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "יש להזין כתובת URL אחת של CivitAI או CivArchive בכל שורה. תומך במספר כתובות URL להורדה בבת אחת.",
"urlHint": "יש להזין כתובת URL אחת של CivitAI, CivArchive או Hugging Face בכל שורה. תומך במספר כתובות URL להורדה בקבוצה.",
"selectHfFiles": "בחר קבצים להורדה ממאגר זה:",
"selectAll": "בחר הכל",
"fetchingRepoFiles": "מביא קבצים מהמאגר...",
"locationPreview": "תצוגה מקדימה של מיקום ההורדה",
"useDefaultPath": "השתמש בנתיב ברירת מחדל",
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
@@ -1156,7 +1186,9 @@
},
"errors": {
"invalidUrl": "פורמט URL של Civitai לא חוקי",
"noVersions": "אין גרסאות זמינות למודל זה"
"noVersions": "אין גרסאות זמינות למודל זה",
"mixedSources": "לא ניתן לערבב כתובות URL של CivitAI ו-Hugging Face באותה קבוצה.",
"noModelFiles": "לא נמצאו קבצי מודל במאגר זה."
},
"status": {
"preparing": "מכין הורדה...",
@@ -1307,6 +1339,8 @@
"editVersionName": "ערוך שם גרסה",
"viewOnCivitai": "הצג ב-Civitai",
"viewOnCivitaiText": "הצג ב-Civitai",
"viewOnHuggingFace": "צפייה ב-Hugging Face",
"viewOnHuggingFaceText": "צפייה ב-Hugging Face",
"viewCreatorProfile": "הצג פרופיל יוצר",
"openFileLocation": "פתח מיקום קובץ",
"sendToWorkflow": "שלח ל-ComfyUI",
@@ -1332,7 +1366,10 @@
"additionalNotes": "הערות נוספות",
"notesHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
"addNotesPlaceholder": "הוסף את ההערות שלך כאן...",
"aboutThisVersion": "אודות גרסה זו"
"aboutThisVersion": "אודות גרסה זו",
"baseModelSearchPlaceholder": "חפש מודל בסיס…",
"baseModelSuggested": "מוצע",
"baseModelNoMatch": "אין מודלי בסיס תואמים"
},
"notes": {
"saved": "הערות נשמרו בהצלחה",
@@ -1608,12 +1645,15 @@
"modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל",
"embeddingAdded": "Embedding נוסף ל-workflow",
"embeddingFailed": "הוספת Embedding נכשלה"
"embeddingFailed": "הוספת Embedding נכשלה",
"promptSent": "הנחיה נשלחה ל-workflow",
"promptFailed": "שליחת ההנחיה נכשלה"
},
"nodeSelector": {
"recipe": "מתכון",
"lora": "LoRA",
"embedding": "Embedding",
"prompt": "הנחיה",
"replace": "החלף",
"append": "הוסף",
"selectTargetNode": "בחר צומת יעד",
@@ -1800,6 +1840,7 @@
"enterLoraName": "אנא הזן שם LoRA או תחביר",
"reconnectedSuccessfully": "LoRA קושר מחדש בהצלחה",
"reconnectFailed": "שגיאה בקישור מחדש של LoRA: {message}",
"noPromptToSend": "אין הנחיה לשליחה",
"cannotSend": "לא ניתן לשלוח מתכון: חסר מזהה מתכון",
"sendFailed": "שליחת המתכון ל-workflow נכשלה",
"sendError": "שגיאה בשליחת המתכון ל-workflow",
@@ -2059,6 +2100,12 @@
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "הועתק ללוח",
"downloadStarted": "ההורדה החלה"
},
"agent": {
"llmNotConfigured": "ספק AI לא הוגדר. הפעל אותו בהגדרות → ספק AI.",
"enrichStarted": "מעשיר מטא-דאטה באמצעות AI...",
"enrichComplete": "העשרת מטא-דאטה הושלמה: {{summary}}",
"enrichFailed": "העשרת מטא-דאטה נכשלה: {{error}}"
}
},
"doctor": {
+63 -16
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "お気に入りから削除",
"viewOnCivitai": "Civitaiで表示",
"notAvailableFromCivitai": "Civitaiでは利用できません",
"viewOnHuggingFace": "Hugging Face で見る",
"sendToWorkflow": "ComfyUIに送信(クリック:追加、Shift+クリック:置換)",
"copyLoRASyntax": "LoRA構文をコピー",
"checkpointNameCopied": "checkpointの名前をコピーしました",
@@ -145,6 +146,10 @@
},
"usage": {
"timesUsed": "使用回数"
},
"footer": {
"versionCount": "{count} バージョン",
"viewAllVersions": "ローカルの全バージョンを表示"
}
},
"globalContextMenu": {
@@ -183,6 +188,9 @@
},
"manageExcludedModels": {
"label": "除外モデルを管理"
},
"groupByModel": {
"label": "モデルでグループ化"
}
},
"header": {
@@ -195,13 +203,7 @@
"statistics": "統計"
},
"search": {
"placeholder": "検索...",
"placeholders": {
"loras": "LoRAを検索...",
"recipes": "レシピを検索...",
"checkpoints": "checkpointを検索...",
"embeddings": "embeddingを検索..."
},
"placeholder": "検索",
"options": "検索オプション",
"searchIn": "検索対象:",
"notAvailable": "統計ページでは検索は利用できません",
@@ -325,7 +327,7 @@
"extraFolderPaths": "追加フォルダーパス",
"downloadPathTemplates": "ダウンロードパステンプレート",
"priorityTags": "優先タグ",
"updateFlags": "アップデートフラグ",
"versionScope": "アップデートフラグ",
"exampleImages": "例画像",
"autoOrganize": "自動整理",
"metadata": "メタデータ",
@@ -430,6 +432,8 @@
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
},
"layoutSettings": {
"groupByModel": "モデルでグループ化",
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
"displayDensity": "表示密度",
"displayDensityOptions": {
"default": "デフォルト",
@@ -586,7 +590,7 @@
"download": "ダウンロード",
"restartRequired": "再起動が必要"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "アップデートフラグの表示戦略",
"help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。",
"options": {
@@ -653,6 +657,23 @@
"proxyPassword": "パスワード(任意)",
"proxyPasswordPlaceholder": "パスワード",
"proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)"
},
"aiProvider": {
"title": "AIプロバイダー",
"provider": "プロバイダー",
"providerHelp": "LLMプロバイダーを選択してください。OpenAIとOllamaはプリセットのAPIエンドポイントを使用します。カスタムでは任意のOpenAI互換エンドポイントを指定できます。",
"custom": "カスタム(OpenAI互換)",
"apiBase": "APIベースURL",
"apiBaseHelp": "LLM APIのベースURL(例:https://api.openai.com/v1)。空の場合はプロバイダーのデフォルトが使用されます。",
"apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "APIキー",
"apiKeyHelp": "LLMプロバイダーのAPIキー。ローカルに保存され、選択したLLMプロバイダー以外のサーバーに送信されることはありません。",
"apiKeyPlaceholder": "sk-...",
"apiKeyNotSet": "未設定",
"apiKeyConfigured": "設定済み",
"apiKeySet": "設定",
"model": "モデル",
"modelHelp": "使用するモデル名(例:deepseek-v4-flash, gemini-2.5-flash, gemma4:12b)。プロバイダーで利用可能なモデルをご確認ください。"
}
},
"loras": {
@@ -670,7 +691,11 @@
"sizeAsc": "小さい順",
"usage": "使用回数",
"usageDesc": "多い",
"usageAsc": "少ない"
"usageAsc": "少ない",
"versionsCount": "ローカルバージョン数",
"versionsCountDesc": "バージョン数の多い順",
"versionsCountAsc": "バージョン数の少ない順",
"versionIdDesc": "最新バージョン順"
},
"refresh": {
"title": "モデルリストを更新",
@@ -746,7 +771,8 @@
"completed": "完了:{success} 移動、{skipped} スキップ、{failures} 失敗",
"complete": "自動整理が完了しました",
"error": "エラー:{error}"
}
},
"enrichHfAgent": "メタデータをAIで補完"
},
"contextMenu": {
"refreshMetadata": "Civitaiデータを更新",
@@ -770,7 +796,8 @@
"shareRecipe": "レシピを共有",
"viewAllLoras": "すべてのLoRAを表示",
"downloadMissingLoras": "不足しているLoRAをダウンロード",
"deleteRecipe": "レシピを削除"
"deleteRecipe": "レシピを削除",
"enrichHfAgent": "メタデータをAIで補完"
}
},
"recipes": {
@@ -1127,7 +1154,10 @@
"titleWithType": "URLから{type}をダウンロード",
"civitaiUrl": "Civitai URL",
"placeholder": "https://civitai.com/models/...",
"urlHint": "1行に1つのCivitAIまたはCivArchive URLを入力してください。複数のURLを一括ダウンロードできます。",
"urlHint": "1行に1つのCivitAICivArchive、またはHugging Face URLを入力してください。複数のURLを一括ダウンロードできます。",
"selectHfFiles": "このリポジトリからダウンロードするファイルを選択してください:",
"selectAll": "すべて選択",
"fetchingRepoFiles": "リポジトリのファイルを取得中...",
"locationPreview": "ダウンロード場所プレビュー",
"useDefaultPath": "デフォルトパスを使用",
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
@@ -1156,7 +1186,9 @@
},
"errors": {
"invalidUrl": "無効なCivitai URL形式",
"noVersions": "このモデルの利用可能なバージョンがありません"
"noVersions": "このモデルの利用可能なバージョンがありません",
"mixedSources": "同じバッチ内でCivitAIとHugging FaceのURLを混在させることはできません。",
"noModelFiles": "このリポジトリにモデルファイルが見つかりませんでした。"
},
"status": {
"preparing": "ダウンロードを準備中...",
@@ -1307,6 +1339,8 @@
"editVersionName": "バージョン名を編集",
"viewOnCivitai": "Civitaiで表示",
"viewOnCivitaiText": "Civitaiで表示",
"viewOnHuggingFace": "Hugging Face で見る",
"viewOnHuggingFaceText": "Hugging Face で見る",
"viewCreatorProfile": "作成者プロフィールを表示",
"openFileLocation": "ファイルの場所を開く",
"sendToWorkflow": "ComfyUI に送信",
@@ -1332,7 +1366,10 @@
"additionalNotes": "追加メモ",
"notesHint": "Enterで保存、Shift+Enterで改行",
"addNotesPlaceholder": "メモをここに追加...",
"aboutThisVersion": "このバージョンについて"
"aboutThisVersion": "このバージョンについて",
"baseModelSearchPlaceholder": "ベースモデルを検索…",
"baseModelSuggested": "おすすめ",
"baseModelNoMatch": "該当するベースモデルがありません"
},
"notes": {
"saved": "メモが正常に保存されました",
@@ -1608,12 +1645,15 @@
"modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました",
"embeddingAdded": "Embeddingをワークフローに追加しました",
"embeddingFailed": "Embeddingの追加に失敗しました"
"embeddingFailed": "Embeddingの追加に失敗しました",
"promptSent": "プロンプトをワークフローに送信しました",
"promptFailed": "プロンプトの送信に失敗しました"
},
"nodeSelector": {
"recipe": "レシピ",
"lora": "LoRA",
"embedding": "Embedding",
"prompt": "プロンプト",
"replace": "置換",
"append": "追加",
"selectTargetNode": "ターゲットノードを選択",
@@ -1800,6 +1840,7 @@
"enterLoraName": "LoRA名または構文を入力してください",
"reconnectedSuccessfully": "LoRAが正常に再接続されました",
"reconnectFailed": "LoRA再接続エラー:{message}",
"noPromptToSend": "送信するプロンプトがありません",
"cannotSend": "レシピを送信できません:レシピIDがありません",
"sendFailed": "レシピのワークフローへの送信に失敗しました",
"sendError": "レシピのワークフロー送信エラー",
@@ -2059,6 +2100,12 @@
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "クリップボードにコピーしました",
"downloadStarted": "ダウンロードを開始しました"
},
"agent": {
"llmNotConfigured": "AIプロバイダーが設定されていません。設定 → AIプロバイダーで有効にしてください。",
"enrichStarted": "AIでメタデータを補完中...",
"enrichComplete": "メタデータの補完が完了しました:{{summary}}",
"enrichFailed": "メタデータの補完に失敗しました:{{error}}"
}
},
"doctor": {
+63 -16
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "즐겨찾기에서 제거",
"viewOnCivitai": "Civitai에서 보기",
"notAvailableFromCivitai": "Civitai에서 사용할 수 없음",
"viewOnHuggingFace": "Hugging Face에서 보기",
"sendToWorkflow": "ComfyUI로 전송 (클릭: 추가, Shift+클릭: 교체)",
"copyLoRASyntax": "LoRA 문법 복사",
"checkpointNameCopied": "Checkpoint 이름 복사됨",
@@ -145,6 +146,10 @@
},
"usage": {
"timesUsed": "사용 횟수"
},
"footer": {
"versionCount": "{count}개 버전",
"viewAllVersions": "모든 로컬 버전 보기"
}
},
"globalContextMenu": {
@@ -183,6 +188,9 @@
},
"manageExcludedModels": {
"label": "제외된 모델 관리"
},
"groupByModel": {
"label": "모델별 그룹화"
}
},
"header": {
@@ -195,13 +203,7 @@
"statistics": "통계"
},
"search": {
"placeholder": "검색...",
"placeholders": {
"loras": "LoRA 검색...",
"recipes": "레시피 검색...",
"checkpoints": "Checkpoint 검색...",
"embeddings": "Embedding 검색..."
},
"placeholder": "검색",
"options": "검색 옵션",
"searchIn": "검색 범위:",
"notAvailable": "통계 페이지에서는 검색을 사용할 수 없습니다",
@@ -325,7 +327,7 @@
"extraFolderPaths": "추가 폴다 경로",
"downloadPathTemplates": "다운로드 경로 템플릿",
"priorityTags": "우선순위 태그",
"updateFlags": "업데이트 표시",
"versionScope": "업데이트 표시",
"exampleImages": "예시 이미지",
"autoOrganize": "자동 정리",
"metadata": "메타데이터",
@@ -430,6 +432,8 @@
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
},
"layoutSettings": {
"groupByModel": "모델별 그룹화",
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
"displayDensity": "표시 밀도",
"displayDensityOptions": {
"default": "기본",
@@ -586,7 +590,7 @@
"download": "다운로드",
"restartRequired": "재시작 필요"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "업데이트 표시 전략",
"help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.",
"options": {
@@ -653,6 +657,23 @@
"proxyPassword": "비밀번호 (선택사항)",
"proxyPasswordPlaceholder": "password",
"proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)"
},
"aiProvider": {
"title": "AI 제공자",
"provider": "제공자",
"providerHelp": "LLM 제공자를 선택하세요. OpenAI와 Ollama는 사전 설정된 API 엔드포인트를 사용합니다. 사용자 정의를 선택하면 모든 OpenAI 호환 엔드포인트를 지정할 수 있습니다.",
"custom": "사용자 정의 (OpenAI 호환)",
"apiBase": "API 기본 URL",
"apiBaseHelp": "LLM API의 기본 URL입니다 (예: https://api.openai.com/v1). 비워두면 제공자 기본값이 사용됩니다.",
"apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "API 키",
"apiKeyHelp": "LLM 제공자의 API 키입니다. 로컬에 저장되며 선택한 LLM 제공자 외의 서버로 전송되지 않습니다.",
"apiKeyPlaceholder": "sk-...",
"apiKeyNotSet": "설정되지 않음",
"apiKeyConfigured": "설정됨",
"apiKeySet": "설정",
"model": "모델",
"modelHelp": "사용할 모델 이름 (예: deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). 제공자에서 사용 가능한 모델을 확인하세요."
}
},
"loras": {
@@ -670,7 +691,11 @@
"sizeAsc": "작은 순서",
"usage": "사용 횟수",
"usageDesc": "많은 순",
"usageAsc": "적은 순"
"usageAsc": "적은 순",
"versionsCount": "로컬 버전 수",
"versionsCountDesc": "버전 수 많은 순",
"versionsCountAsc": "버전 수 적은 순",
"versionIdDesc": "최신 버전순"
},
"refresh": {
"title": "모델 목록 새로고침",
@@ -746,7 +771,8 @@
"completed": "완료: {success}개 이동, {skipped}개 건너뜀, {failures}개 실패",
"complete": "자동 정리 완료",
"error": "오류: {error}"
}
},
"enrichHfAgent": "AI로 메타데이터 보강"
},
"contextMenu": {
"refreshMetadata": "Civitai 데이터 새로고침",
@@ -770,7 +796,8 @@
"shareRecipe": "레시피 공유",
"viewAllLoras": "모든 LoRA 보기",
"downloadMissingLoras": "누락된 LoRA 다운로드",
"deleteRecipe": "레시피 삭제"
"deleteRecipe": "레시피 삭제",
"enrichHfAgent": "AI로 메타데이터 보강"
}
},
"recipes": {
@@ -1127,7 +1154,10 @@
"titleWithType": "URL에서 {type} 다운로드",
"civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "한 줄에 하나의 CivitAI 또는 CivArchive URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
"urlHint": "한 줄에 하나의 CivitAI, CivArchive 또는 Hugging Face URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
"selectHfFiles": "이 저장소에서 다운로드할 파일을 선택하세요:",
"selectAll": "모두 선택",
"fetchingRepoFiles": "저장소 파일을 가져오는 중...",
"locationPreview": "다운로드 위치 미리보기",
"useDefaultPath": "기본 경로 사용",
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
@@ -1156,7 +1186,9 @@
},
"errors": {
"invalidUrl": "잘못된 Civitai URL 형식",
"noVersions": "이 모델에 사용 가능한 버전이 없습니다"
"noVersions": "이 모델에 사용 가능한 버전이 없습니다",
"mixedSources": "동일한 배치에서 CivitAI와 Hugging Face URL을 혼합할 수 없습니다.",
"noModelFiles": "이 저장소에서 모델 파일을 찾을 수 없습니다."
},
"status": {
"preparing": "다운로드 준비 중...",
@@ -1307,6 +1339,8 @@
"editVersionName": "버전명 편집",
"viewOnCivitai": "Civitai에서 보기",
"viewOnCivitaiText": "Civitai에서 보기",
"viewOnHuggingFace": "Hugging Face에서 보기",
"viewOnHuggingFaceText": "Hugging Face에서 보기",
"viewCreatorProfile": "제작자 프로필 보기",
"openFileLocation": "파일 위치 열기",
"sendToWorkflow": "ComfyUI로 보내기",
@@ -1332,7 +1366,10 @@
"additionalNotes": "추가 메모",
"notesHint": "Enter로 저장, Shift+Enter로 줄바꿈",
"addNotesPlaceholder": "메모를 여기에 추가하세요...",
"aboutThisVersion": "이 버전에 대해"
"aboutThisVersion": "이 버전에 대해",
"baseModelSearchPlaceholder": "베이스 모델 검색…",
"baseModelSuggested": "추천",
"baseModelNoMatch": "일치하는 베이스 모델 없음"
},
"notes": {
"saved": "메모가 성공적으로 저장됨",
@@ -1608,12 +1645,15 @@
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패",
"embeddingAdded": "Embedding을 워크플로에 추가했습니다",
"embeddingFailed": "Embedding 추가 실패"
"embeddingFailed": "Embedding 추가 실패",
"promptSent": "프롬프트를 워크플로에 보냈습니다",
"promptFailed": "프롬프트 보내기 실패"
},
"nodeSelector": {
"recipe": "레시피",
"lora": "LoRA",
"embedding": "임베딩",
"prompt": "프롬프트",
"replace": "교체",
"append": "추가",
"selectTargetNode": "대상 노드 선택",
@@ -1800,6 +1840,7 @@
"enterLoraName": "LoRA 이름 또는 문법을 입력해주세요",
"reconnectedSuccessfully": "LoRA가 성공적으로 다시 연결되었습니다",
"reconnectFailed": "LoRA 다시 연결 오류: {message}",
"noPromptToSend": "보낼 프롬프트가 없습니다",
"cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락",
"sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다",
"sendError": "레시피를 워크플로로 전송하는 중 오류",
@@ -2059,6 +2100,12 @@
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "클립보드에 복사됨",
"downloadStarted": "다운로드 시작됨"
},
"agent": {
"llmNotConfigured": "AI 제공자가 설정되지 않았습니다. 설정 → AI 제공자에서 활성화하세요.",
"enrichStarted": "AI로 메타데이터 보강 중...",
"enrichComplete": "메타데이터 보강 완료: {{summary}}",
"enrichFailed": "메타데이터 보강 실패: {{error}}"
}
},
"doctor": {
+63 -16
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "Удалить из избранного",
"viewOnCivitai": "Посмотреть на Civitai",
"notAvailableFromCivitai": "Недоступно на Civitai",
"viewOnHuggingFace": "Открыть Hugging Face",
"sendToWorkflow": "Отправить в ComfyUI (Клик: Добавить, Shift+Клик: Заменить)",
"copyLoRASyntax": "Копировать синтаксис LoRA",
"checkpointNameCopied": "Имя checkpoint скопировано",
@@ -145,6 +146,10 @@
},
"usage": {
"timesUsed": "Количество использований"
},
"footer": {
"versionCount": "{count} версий",
"viewAllVersions": "Показать все локальные версии"
}
},
"globalContextMenu": {
@@ -183,6 +188,9 @@
},
"manageExcludedModels": {
"label": "Управление исключёнными моделями"
},
"groupByModel": {
"label": "Группировать по модели"
}
},
"header": {
@@ -195,13 +203,7 @@
"statistics": "Статистика"
},
"search": {
"placeholder": "Поиск...",
"placeholders": {
"loras": "Поиск LoRAs...",
"recipes": "Поиск рецептов...",
"checkpoints": "Поиск checkpoints...",
"embeddings": "Поиск embeddings..."
},
"placeholder": "Поиск",
"options": "Опции поиска",
"searchIn": "Искать в:",
"notAvailable": "Поиск недоступен на странице статистики",
@@ -325,7 +327,7 @@
"extraFolderPaths": "Дополнительные пути к папкам",
"downloadPathTemplates": "Шаблоны путей загрузки",
"priorityTags": "Приоритетные теги",
"updateFlags": "Метки обновлений",
"versionScope": "Метки обновлений",
"exampleImages": "Примеры изображений",
"autoOrganize": "Автоорганизация",
"metadata": "Метаданные",
@@ -430,6 +432,8 @@
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
},
"layoutSettings": {
"groupByModel": "Группировать по модели",
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
"displayDensity": "Плотность отображения",
"displayDensityOptions": {
"default": "По умолчанию",
@@ -586,7 +590,7 @@
"download": "Загрузить",
"restartRequired": "Требует перезапуска"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "Стратегия меток обновлений",
"help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.",
"options": {
@@ -653,6 +657,23 @@
"proxyPassword": "Пароль (необязательно)",
"proxyPasswordPlaceholder": "пароль",
"proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)"
},
"aiProvider": {
"title": "Поставщик ИИ",
"provider": "Поставщик",
"providerHelp": "Выберите поставщика LLM. OpenAI и Ollama используют предустановленные API-эндпоинты. Пользовательский позволяет указать любой совместимый с OpenAI эндпоинт.",
"custom": "Пользовательский (совместимый с OpenAI)",
"apiBase": "Базовый URL API",
"apiBaseHelp": "Базовый URL для LLM API (например, https://api.openai.com/v1). Оставьте пустым, чтобы использовать значение по умолчанию.",
"apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "API-ключ",
"apiKeyHelp": "Ваш API-ключ поставщика LLM. Хранится локально и никогда не отправляется на другие серверы, кроме выбранного поставщика LLM.",
"apiKeyPlaceholder": "sk-...",
"apiKeyNotSet": "Не задан",
"apiKeyConfigured": "Настроен",
"apiKeySet": "Настроить",
"model": "Модель",
"modelHelp": "Имя модели для использования (например, deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Проверьте доступные модели у вашего поставщика."
}
},
"loras": {
@@ -670,7 +691,11 @@
"sizeAsc": "Наименьшим",
"usage": "Число использований",
"usageDesc": "Больше",
"usageAsc": "Меньше"
"usageAsc": "Меньше",
"versionsCount": "Локальные версии",
"versionsCountDesc": "Сначала больше версий",
"versionsCountAsc": "Сначала меньше версий",
"versionIdDesc": "Сначала новые версии"
},
"refresh": {
"title": "Обновить список моделей",
@@ -746,7 +771,8 @@
"completed": "Завершено: {success} перемещено, {skipped} пропущено, {failures} не удалось",
"complete": "Автоматическая организация завершена",
"error": "Ошибка: {error}"
}
},
"enrichHfAgent": "Обогатить метаданные (ИИ)"
},
"contextMenu": {
"refreshMetadata": "Обновить данные Civitai",
@@ -770,7 +796,8 @@
"shareRecipe": "Поделиться рецептом",
"viewAllLoras": "Посмотреть все LoRAs",
"downloadMissingLoras": "Загрузить отсутствующие LoRAs",
"deleteRecipe": "Удалить рецепт"
"deleteRecipe": "Удалить рецепт",
"enrichHfAgent": "Обогатить метаданные (ИИ)"
}
},
"recipes": {
@@ -1127,7 +1154,10 @@
"titleWithType": "Скачать {type} по URL",
"civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Введите один URL CivitAI или CivArchive в каждой строке. Поддерживается пакетная загрузка нескольких URL.",
"urlHint": "Введите один URL CivitAI, CivArchive или Hugging Face в каждой строке. Поддерживает несколько URL для пакетной загрузки.",
"selectHfFiles": "Выберите файл(ы) для загрузки из этого репозитория:",
"selectAll": "Выбрать все",
"fetchingRepoFiles": "Получение файлов репозитория...",
"locationPreview": "Предпросмотр места загрузки",
"useDefaultPath": "Использовать путь по умолчанию",
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
@@ -1156,7 +1186,9 @@
},
"errors": {
"invalidUrl": "Неверный формат URL Civitai",
"noVersions": "Нет доступных версий для этой модели"
"noVersions": "Нет доступных версий для этой модели",
"mixedSources": "Нельзя смешивать URL-адреса CivitAI и Hugging Face в одном пакете.",
"noModelFiles": "В этом репозитории не найдено файлов моделей."
},
"status": {
"preparing": "Подготовка загрузки...",
@@ -1307,6 +1339,8 @@
"editVersionName": "Редактировать название версии",
"viewOnCivitai": "Посмотреть на Civitai",
"viewOnCivitaiText": "Посмотреть на Civitai",
"viewOnHuggingFace": "Открыть Hugging Face",
"viewOnHuggingFaceText": "Открыть Hugging Face",
"viewCreatorProfile": "Посмотреть профиль создателя",
"openFileLocation": "Открыть расположение файла",
"sendToWorkflow": "Отправить в ComfyUI",
@@ -1332,7 +1366,10 @@
"additionalNotes": "Дополнительные заметки",
"notesHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
"addNotesPlaceholder": "Добавьте ваши заметки здесь...",
"aboutThisVersion": "Об этой версии"
"aboutThisVersion": "Об этой версии",
"baseModelSearchPlaceholder": "Поиск базовой модели…",
"baseModelSuggested": "Предполагаемые",
"baseModelNoMatch": "Нет подходящих базовых моделей"
},
"notes": {
"saved": "Заметки успешно сохранены",
@@ -1608,12 +1645,15 @@
"modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели",
"embeddingAdded": "Embedding добавлен в workflow",
"embeddingFailed": "Не удалось добавить embedding"
"embeddingFailed": "Не удалось добавить embedding",
"promptSent": "Запрос отправлен в workflow",
"promptFailed": "Не удалось отправить запрос"
},
"nodeSelector": {
"recipe": "Рецепт",
"lora": "LoRA",
"embedding": "Эмбеддинг",
"prompt": "Запрос",
"replace": "Заменить",
"append": "Добавить",
"selectTargetNode": "Выберите целевой узел",
@@ -1800,6 +1840,7 @@
"enterLoraName": "Пожалуйста, введите название LoRA или синтаксис",
"reconnectedSuccessfully": "LoRA успешно переподключена",
"reconnectFailed": "Ошибка переподключения LoRA: {message}",
"noPromptToSend": "Нет запроса для отправки",
"cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта",
"sendFailed": "Не удалось отправить рецепт в workflow",
"sendError": "Ошибка отправки рецепта в workflow",
@@ -2059,6 +2100,12 @@
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Скопировано в буфер обмена",
"downloadStarted": "Загрузка начата"
},
"agent": {
"llmNotConfigured": "Поставщик ИИ не настроен. Включите его в Настройки → Поставщик ИИ.",
"enrichStarted": "Обогащение метаданных с помощью ИИ...",
"enrichComplete": "Обогащение метаданных завершено: {{summary}}",
"enrichFailed": "Ошибка обогащения метаданных: {{error}}"
}
},
"doctor": {
+67 -20
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "从收藏移除",
"viewOnCivitai": "在 Civitai 查看",
"notAvailableFromCivitai": "Civitai 上不可用",
"viewOnHuggingFace": "在 Hugging Face 查看",
"sendToWorkflow": "发送到 ComfyUI(点击:追加,Shift+点击:替换)",
"copyLoRASyntax": "复制 LoRA 语法",
"checkpointNameCopied": "检查点名称已复制",
@@ -145,6 +146,10 @@
},
"usage": {
"timesUsed": "使用次数"
},
"footer": {
"versionCount": "{count} 个版本",
"viewAllVersions": "查看所有本地版本"
}
},
"globalContextMenu": {
@@ -183,6 +188,9 @@
},
"manageExcludedModels": {
"label": "管理已排除的模型"
},
"groupByModel": {
"label": "按模型分组"
}
},
"header": {
@@ -195,13 +203,7 @@
"statistics": "统计"
},
"search": {
"placeholder": "搜索...",
"placeholders": {
"loras": "搜索 LoRA...",
"recipes": "搜索配方...",
"checkpoints": "搜索 Checkpoint...",
"embeddings": "搜索 Embedding..."
},
"placeholder": "搜索",
"options": "搜索选项",
"searchIn": "搜索范围:",
"notAvailable": "统计页面不可用搜索",
@@ -325,7 +327,7 @@
"extraFolderPaths": "额外文件夹路径",
"downloadPathTemplates": "下载路径模板",
"priorityTags": "优先标签",
"updateFlags": "更新标记",
"versionScope": "版本范围",
"exampleImages": "示例图片",
"autoOrganize": "自动整理",
"metadata": "元数据",
@@ -430,6 +432,8 @@
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
},
"layoutSettings": {
"groupByModel": "按模型分组",
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
"displayDensity": "显示密度",
"displayDensityOptions": {
"default": "默认",
@@ -586,12 +590,12 @@
"download": "下载",
"restartRequired": "需要重启"
},
"updateFlagStrategy": {
"label": "更新标记策略",
"help": "决定更新徽章是否仅在新版本与本地文件共享相同基础模型时显示,或只要该模型有任何更新版本就显示。",
"versionGrouping": {
"label": "版本分组",
"help": "控制版本在 UI 中的分组方式:按基础模型分组或合并显示。同时影响更新徽章逻辑和版本列表的筛选行为。",
"options": {
"sameBase": "按基础模型匹配更新",
"any": "显示任何可用更新"
"sameBase": "按基础模型分组",
"any": "显示所有版本"
}
},
"hideEarlyAccessUpdates": {
@@ -653,6 +657,23 @@
"proxyPassword": "密码 (可选)",
"proxyPasswordPlaceholder": "密码",
"proxyPasswordHelp": "代理认证的密码 (如果需要)"
},
"aiProvider": {
"title": "AI 提供商",
"provider": "提供商",
"providerHelp": "选择您的 LLM 提供商。OpenAI 和 Ollama 使用预设的 API 端点。自定义允许您指定任何兼容 OpenAI 的端点。",
"custom": "自定义(兼容 OpenAI",
"apiBase": "API 基础地址",
"apiBaseHelp": "LLM API 的基础 URL(例如 https://api.openai.com/v1)。留空则使用提供商默认地址。",
"apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "API 密钥",
"apiKeyHelp": "您的 LLM 提供商 API 密钥。仅本地存储,不会发送到您选择的 LLM 提供商之外的任何服务器。",
"apiKeyPlaceholder": "sk-...",
"apiKeyNotSet": "未设置",
"apiKeyConfigured": "已配置",
"apiKeySet": "设置",
"model": "模型",
"modelHelp": "要使用的模型名称(例如 deepseek-v4-flash, gemini-2.5-flash, gemma4:12b)。请查看您的提供商支持的可用模型列表。"
}
},
"loras": {
@@ -670,7 +691,11 @@
"sizeAsc": "最小",
"usage": "使用次数",
"usageDesc": "最多",
"usageAsc": "最少"
"usageAsc": "最少",
"versionsCount": "本地版本数",
"versionsCountDesc": "版本数从多到少",
"versionsCountAsc": "版本数从少到多",
"versionIdDesc": "最新版本优先"
},
"refresh": {
"title": "刷新模型列表",
@@ -746,7 +771,8 @@
"completed": "完成:已移动 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
"complete": "自动整理已完成",
"error": "错误:{error}"
}
},
"enrichHfAgent": "AI 元数据增强"
},
"contextMenu": {
"refreshMetadata": "刷新 Civitai 数据",
@@ -770,7 +796,8 @@
"shareRecipe": "分享配方",
"viewAllLoras": "查看所有 LoRA",
"downloadMissingLoras": "下载缺失的 LoRA",
"deleteRecipe": "删除配方"
"deleteRecipe": "删除配方",
"enrichHfAgent": "AI 元数据增强"
}
},
"recipes": {
@@ -1127,7 +1154,10 @@
"titleWithType": "从 URL 下载 {type}",
"civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "每行输入一个 CivitAICivArchive URL。支持批量下载多个 URL。",
"urlHint": "每行输入一个 CivitAICivArchive 或 Hugging Face URL。支持批量下载多个 URL。",
"selectHfFiles": "选择从此仓库下载的文件:",
"selectAll": "全选",
"fetchingRepoFiles": "正在获取仓库文件...",
"locationPreview": "下载位置预览",
"useDefaultPath": "使用默认路径",
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
@@ -1156,7 +1186,9 @@
},
"errors": {
"invalidUrl": "无效的 Civitai URL 格式",
"noVersions": "此模型没有可用版本"
"noVersions": "此模型没有可用版本",
"mixedSources": "无法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
"noModelFiles": "在此仓库中未找到模型文件。"
},
"status": {
"preparing": "正在准备下载...",
@@ -1307,6 +1339,8 @@
"editVersionName": "编辑版本名称",
"viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看",
"viewOnHuggingFace": "在 Hugging Face 查看",
"viewOnHuggingFaceText": "在 Hugging Face 查看",
"viewCreatorProfile": "查看创作者主页",
"openFileLocation": "打开文件位置",
"sendToWorkflow": "发送到 ComfyUI",
@@ -1332,7 +1366,10 @@
"additionalNotes": "附加备注",
"notesHint": "回车保存,Shift+回车换行",
"addNotesPlaceholder": "在此添加你的备注...",
"aboutThisVersion": "关于此版本"
"aboutThisVersion": "关于此版本",
"baseModelSearchPlaceholder": "搜索基础模型…",
"baseModelSuggested": "推荐",
"baseModelNoMatch": "没有匹配的基础模型"
},
"notes": {
"saved": "备注保存成功",
@@ -1608,12 +1645,15 @@
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型节点失败",
"embeddingAdded": "Embedding 已追加到工作流",
"embeddingFailed": "添加 Embedding 失败"
"embeddingFailed": "添加 Embedding 失败",
"promptSent": "提示词已发送到工作流",
"promptFailed": "提示词发送失败"
},
"nodeSelector": {
"recipe": "配方",
"lora": "LoRA",
"embedding": "Embedding",
"prompt": "提示词",
"replace": "替换",
"append": "追加",
"selectTargetNode": "选择目标节点",
@@ -1800,6 +1840,7 @@
"enterLoraName": "请输入 LoRA 名称或语法",
"reconnectedSuccessfully": "LoRA 重新连接成功",
"reconnectFailed": "LoRA 重新连接出错:{message}",
"noPromptToSend": "没有可发送的提示词",
"cannotSend": "无法发送配方:缺少配方 ID",
"sendFailed": "发送配方到工作流失败",
"sendError": "发送配方到工作流出错",
@@ -2059,6 +2100,12 @@
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "已复制到剪贴板",
"downloadStarted": "下载已开始"
},
"agent": {
"llmNotConfigured": "AI 提供商未配置。请在 设置 → AI 提供商 中进行配置。",
"enrichStarted": "正在使用 AI 增强元数据...",
"enrichComplete": "元数据增强完成:{{summary}}",
"enrichFailed": "元数据增强失败:{{error}}"
}
},
"doctor": {
+63 -16
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "移除收藏",
"viewOnCivitai": "在 Civitai 查看",
"notAvailableFromCivitai": "Civitai 不提供",
"viewOnHuggingFace": "在 Hugging Face 查看",
"sendToWorkflow": "傳送到 ComfyUI(點擊:附加,Shift+點擊:取代)",
"copyLoRASyntax": "複製 LoRA 語法",
"checkpointNameCopied": "Checkpoint 名稱已複製",
@@ -145,6 +146,10 @@
},
"usage": {
"timesUsed": "使用次數"
},
"footer": {
"versionCount": "{count} 個版本",
"viewAllVersions": "檢視所有本地版本"
}
},
"globalContextMenu": {
@@ -183,6 +188,9 @@
},
"manageExcludedModels": {
"label": "管理已排除的模型"
},
"groupByModel": {
"label": "按模型分組"
}
},
"header": {
@@ -195,13 +203,7 @@
"statistics": "統計"
},
"search": {
"placeholder": "搜尋...",
"placeholders": {
"loras": "搜尋 LoRA...",
"recipes": "搜尋配方...",
"checkpoints": "搜尋 checkpoint...",
"embeddings": "搜尋 embedding..."
},
"placeholder": "搜尋",
"options": "搜尋選項",
"searchIn": "搜尋範圍:",
"notAvailable": "統計頁面無法搜尋",
@@ -325,7 +327,7 @@
"extraFolderPaths": "額外資料夾路徑",
"downloadPathTemplates": "下載路徑範本",
"priorityTags": "優先標籤",
"updateFlags": "更新標記",
"versionScope": "版本範圍",
"exampleImages": "範例圖片",
"autoOrganize": "自動整理",
"metadata": "中繼資料",
@@ -430,6 +432,8 @@
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
},
"layoutSettings": {
"groupByModel": "按模型分組",
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
"displayDensity": "顯示密度",
"displayDensityOptions": {
"default": "預設",
@@ -586,7 +590,7 @@
"download": "下載",
"restartRequired": "需要重新啟動"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "更新標記策略",
"help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。",
"options": {
@@ -653,6 +657,23 @@
"proxyPassword": "密碼(選填)",
"proxyPasswordPlaceholder": "password",
"proxyPasswordHelp": "代理驗證所需的密碼(如有需要)"
},
"aiProvider": {
"title": "AI 提供者",
"provider": "提供者",
"providerHelp": "選擇您的 LLM 提供者。OpenAI 和 Ollama 使用預設 API 端點。自訂允許您指定任何相容 OpenAI 的端點。",
"custom": "自訂(相容 OpenAI",
"apiBase": "API 基礎位址",
"apiBaseHelp": "LLM API 的基礎 URL(例如 https://api.openai.com/v1)。留空則使用提供者預設位址。",
"apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "API 金鑰",
"apiKeyHelp": "您的 LLM 提供者 API 金鑰。僅儲存在本地,除了您選擇的 LLM 提供者外,不會發送到任何伺服器。",
"apiKeyPlaceholder": "sk-...",
"apiKeyNotSet": "未設定",
"apiKeyConfigured": "已設定",
"apiKeySet": "設定",
"model": "模型",
"modelHelp": "要使用的模型名稱(例如 deepseek-v4-flash, gemini-2.5-flash, gemma4:12b)。請查看您的提供者支援的可用模型列表。"
}
},
"loras": {
@@ -670,7 +691,11 @@
"sizeAsc": "最小",
"usage": "使用次數",
"usageDesc": "最多",
"usageAsc": "最少"
"usageAsc": "最少",
"versionsCount": "本地版本數",
"versionsCountDesc": "版本數從多到少",
"versionsCountAsc": "版本數從少到多",
"versionIdDesc": "最新版本優先"
},
"refresh": {
"title": "重新整理模型列表",
@@ -746,7 +771,8 @@
"completed": "完成:已移動 {success},已略過 {skipped},失敗 {failures}",
"complete": "自動整理完成",
"error": "錯誤:{error}"
}
},
"enrichHfAgent": "AI 中繼資料增強"
},
"contextMenu": {
"refreshMetadata": "刷新 Civitai 資料",
@@ -770,7 +796,8 @@
"shareRecipe": "分享配方",
"viewAllLoras": "檢視全部 LoRA",
"downloadMissingLoras": "下載缺少的 LoRA",
"deleteRecipe": "刪除配方"
"deleteRecipe": "刪除配方",
"enrichHfAgent": "AI 中繼資料增強"
}
},
"recipes": {
@@ -1127,7 +1154,10 @@
"titleWithType": "從網址下載 {type}",
"civitaiUrl": "Civitai 網址:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "每行輸入一個 CivitAICivArchive URL。支援批量下載多個 URL。",
"urlHint": "每行輸入一個 CivitAICivArchive 或 Hugging Face URL。支援批量下載多個 URL。",
"selectHfFiles": "選擇從此倉庫下載的檔案:",
"selectAll": "全選",
"fetchingRepoFiles": "正在獲取倉庫檔案...",
"locationPreview": "下載位置預覽",
"useDefaultPath": "使用預設路徑",
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
@@ -1156,7 +1186,9 @@
},
"errors": {
"invalidUrl": "Civitai 網址格式無效",
"noVersions": "此模型無可用版本"
"noVersions": "此模型無可用版本",
"mixedSources": "無法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
"noModelFiles": "在此倉庫中未找到模型檔案。"
},
"status": {
"preparing": "準備下載中...",
@@ -1307,6 +1339,8 @@
"editVersionName": "編輯版本名稱",
"viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看",
"viewOnHuggingFace": "在 Hugging Face 查看",
"viewOnHuggingFaceText": "在 Hugging Face 查看",
"viewCreatorProfile": "查看創作者個人檔案",
"openFileLocation": "開啟檔案位置",
"sendToWorkflow": "傳送到 ComfyUI",
@@ -1332,7 +1366,10 @@
"additionalNotes": "附加備註",
"notesHint": "按 Enter 儲存,Shift+Enter 換行",
"addNotesPlaceholder": "在此新增備註...",
"aboutThisVersion": "關於此版本"
"aboutThisVersion": "關於此版本",
"baseModelSearchPlaceholder": "搜尋基礎模型…",
"baseModelSuggested": "推薦",
"baseModelNoMatch": "沒有符合的基礎模型"
},
"notes": {
"saved": "備註已儲存",
@@ -1608,12 +1645,15 @@
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型節點失敗",
"embeddingAdded": "Embedding 已附加到工作流",
"embeddingFailed": "傳送 Embedding 到工作流失敗"
"embeddingFailed": "傳送 Embedding 到工作流失敗",
"promptSent": "提示詞已發送到工作流",
"promptFailed": "提示詞發送失敗"
},
"nodeSelector": {
"recipe": "配方",
"lora": "LoRA",
"embedding": "Embedding",
"prompt": "提示詞",
"replace": "取代",
"append": "附加",
"selectTargetNode": "選擇目標節點",
@@ -1800,6 +1840,7 @@
"enterLoraName": "請輸入 LoRA 名稱或語法",
"reconnectedSuccessfully": "LoRA 重新連結成功",
"reconnectFailed": "LoRA 重新連結錯誤:{message}",
"noPromptToSend": "沒有可發送的提示詞",
"cannotSend": "無法傳送配方:缺少配方 ID",
"sendFailed": "傳送配方到工作流失敗",
"sendError": "傳送配方到工作流錯誤",
@@ -2059,6 +2100,12 @@
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "已複製到剪貼簿",
"downloadStarted": "下載已開始"
},
"agent": {
"llmNotConfigured": "AI 提供者尚未設定。請在 設定 → AI 提供者 中進行設定。",
"enrichStarted": "正在使用 AI 增強中繼資料...",
"enrichComplete": "中繼資料增強完成:{{summary}}",
"enrichFailed": "中繼資料增強失敗:{{error}}"
}
},
"doctor": {
+225
View File
@@ -0,0 +1,225 @@
"""Agent CLI — thin in-process wrappers around LoRA Manager internal services.
All functions are simple Python async functions that delegate to the
appropriate internal service. They use **relative imports** within the
``py`` package, so ``sys.modules`` caching works normally and there is no
risk of double import or circular dependencies.
Usage (in-process, primary)::
from py.agent_cli 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
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Any, Dict, List, Optional
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*.
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.
"""
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",
):
getter = getattr(ServiceRegistry, getter_name, None)
if getter is None:
continue
try:
scanner = await getter()
if scanner is None:
continue
cache = await scanner.get_cached_data()
for entry in cache.raw_data:
if os.path.normpath(entry.get("file_path", "")) == normalized:
return scanner, entry
except Exception as exc:
logger.debug(
"Scanner %s check failed for %s: %s",
getter_name,
model_path,
exc,
)
return None, None
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def list_base_models(limit: int = 0) -> List[str]:
"""Return deduplicated base model names from all model caches.
The result is ordered by frequency (most common first). Pass
*limit* = 0 (default) for all models.
"""
from ..services.service_registry import ServiceRegistry
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])]
if limit > 0:
return sorted_names[:limit]
return sorted_names
async def read_metadata(model_path: str) -> Dict[str, Any]:
"""Load the full metadata payload for *model_path* from disk.
Returns an empty dict when the metadata file does not exist or cannot
be parsed — never raises.
"""
from ..utils.metadata_manager import MetadataManager
try:
return await MetadataManager.load_metadata_payload(model_path) or {}
except Exception as exc:
logger.warning("read_metadata failed for %s: %s", model_path, exc)
return {}
async def apply_metadata_updates(
model_path: str,
updates: Dict[str, Any],
) -> List[str]:
"""Merge *updates* into the model's on-disk metadata and persist.
Returns the list of field names that actually changed.
"""
from ..utils.metadata_manager import MetadataManager
metadata = await read_metadata(model_path)
updated_fields: List[str] = []
for key, value in updates.items():
old = metadata.get(key)
if old != value:
metadata[key] = value
updated_fields.append(key)
if updated_fields:
await MetadataManager.save_metadata(model_path, metadata)
return updated_fields
async def download_preview(
model_path: str,
url: str,
*,
target_width: int = 480,
quality: int = 85,
) -> bool:
"""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.
"""
from ..services.downloader import get_downloader
from ..utils.exif_utils import ExifUtils
if not url or not url.strip():
return False
base_name = os.path.splitext(os.path.basename(model_path))[0]
preview_dir = os.path.dirname(model_path)
output_path = os.path.join(preview_dir, base_name + ".webp")
downloader = await get_downloader()
# Try in-memory download + optimise first
success, content, _headers = await downloader.download_to_memory(
url, use_auth=False,
)
if success and content:
try:
optimized_data, _ = ExifUtils.optimize_image(
image_data=content,
target_width=target_width,
format="webp",
quality=quality,
preserve_metadata=False,
)
with open(output_path, "wb") as f:
f.write(optimized_data)
logger.info("Preview downloaded and optimised for %s", model_path)
return True
except Exception as exc:
logger.warning("Preview optimisation failed, saving raw: %s", exc)
# Fall through to raw save
# Fallback: download directly to file
try:
ok, _ = await downloader.download_file(url, output_path, use_auth=False)
if ok:
logger.info("Preview downloaded (fallback) for %s", model_path)
return True
except Exception as exc:
logger.warning("Preview fallback download failed for %s: %s", model_path, exc)
return False
async def refresh_cache(model_path: str) -> bool:
"""Invalidate and reload the scanner cache entry for *model_path*.
Returns ``True`` when the model was found and the cache was refreshed.
"""
scanner, entry = await _find_scanner_for_model(model_path)
if scanner is None:
logger.warning("refresh_cache: no scanner found for %s", model_path)
return False
try:
metadata = await read_metadata(model_path)
if not metadata:
logger.warning("refresh_cache: no metadata for %s", model_path)
return False
await scanner.update_single_model_cache(model_path, model_path, metadata)
return True
except Exception as exc:
logger.warning("refresh_cache failed for %s: %s", model_path, exc)
return False
+118
View File
@@ -0,0 +1,118 @@
"""Subprocess entry point for AgentCLI (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.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from typing import Any, Dict, List
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="lmcli", description="LoRA Manager Agent CLI")
sub = parser.add_subparsers(dest="command", required=True)
# base-models list
base_models = sub.add_parser("base-models", aliases=["bm"])
base_models_cmds = base_models.add_subparsers(dest="subcommand", required=True)
base_models_list = base_models_cmds.add_parser("list")
base_models_list.add_argument(
"--limit", type=int, default=0, help="Max number of models (0 = all)"
)
# metadata read
meta = sub.add_parser("metadata", aliases=["md"])
meta_cmds = meta.add_subparsers(dest="subcommand", required=True)
meta_read = meta_cmds.add_parser("read")
meta_read.add_argument("path", type=str, help="Model file path")
# metadata update
meta_update = meta_cmds.add_parser("update")
meta_update.add_argument("path", type=str, help="Model file path")
meta_update.add_argument(
"--json",
type=str,
required=True,
help='JSON object of fields to update, e.g. \'{"base_model": "SDXL 1.0"}\'',
)
# preview download
prev = sub.add_parser("preview", aliases=["pv"])
prev_cmds = prev.add_subparsers(dest="subcommand", required=True)
prev_dl = prev_cmds.add_parser("download")
prev_dl.add_argument("path", type=str, help="Model file path")
prev_dl.add_argument("--url", type=str, required=True, help="Preview image URL")
# cache refresh
cache = sub.add_parser("cache")
cache_cmds = cache.add_subparsers(dest="subcommand", required=True)
cache_refresh = cache_cmds.add_parser("refresh")
cache_refresh.add_argument("path", type=str, help="Model file path")
return parser
async def _run(args: argparse.Namespace) -> Any:
from . import ( # lazy import so startup is fast
list_base_models,
read_metadata,
apply_metadata_updates,
download_preview,
refresh_cache,
)
cmd = args.command
sub = args.subcommand
if cmd in ("base-models", "bm") and sub == "list":
return await list_base_models(limit=args.limit)
if cmd in ("metadata", "md") and sub == "read":
return await read_metadata(args.path)
if cmd in ("metadata", "md") and sub == "update":
updates: Dict[str, Any] = json.loads(args.json)
return await apply_metadata_updates(args.path, updates)
if cmd in ("preview", "pv") and sub == "download":
return await download_preview(args.path, args.url)
if cmd == "cache" and sub == "refresh":
return await refresh_cache(args.path)
raise ValueError(f"Unknown command: {cmd} {sub}")
def main() -> None:
parser = _build_parser()
args = parser.parse_args()
result = asyncio.run(_run(args))
# Always print as JSON so callers can parse reliably
if isinstance(result, list):
for item in result:
print(item)
elif isinstance(result, dict):
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
print()
else:
print(json.dumps(result))
if __name__ == "__main__":
main()
+19 -1
View File
@@ -8,6 +8,8 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
import logging
import json
import urllib.parse
import sys as _sys
import types as _types
import time
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
@@ -1380,4 +1382,20 @@ class Config:
# Global config instance
config = Config()
# NOTE: Guard against re-import. When ServiceRegistry.get_lora_scanner() triggers
# a fresh import of lora_scanner → config, we must NOT re-execute Config.__init__()
# (which re-scans all roots, re-registers libraries, etc.).
#
# Strategy: store the config instance in a dedicated sentinel module
# ('_lm_config_cache') that is NEVER removed from sys.modules (its key does
# NOT start with 'py.'), so it survives re-imports of py.* modules.
_CONFIG_SENTINEL = "_lm_config_cache"
if _CONFIG_SENTINEL in _sys.modules:
# Re-import: reuse the existing singleton from the sentinel.
config: Config = _sys.modules[_CONFIG_SENTINEL].config # type: ignore[valid-type]
else:
config: Config = Config()
# Register the sentinel so re-imports of py.config find us.
_sentinel_mod = _types.ModuleType(_CONFIG_SENTINEL)
_sentinel_mod.config = config
_sys.modules[_CONFIG_SENTINEL] = _sentinel_mod
+7
View File
@@ -445,5 +445,12 @@ class LoraManager:
scanner.cancel_task()
logger.debug("LoRA Manager: Cancelled %s", name)
# Close shared aiohttp sessions to avoid "Unclosed client session" warnings
try:
from py.routes.handlers.hf_handlers import close_hf_api_session
await close_hf_api_session()
except Exception as exc:
logger.debug("Error closing HF API session: %s", exc)
except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True)
+1 -1
View File
@@ -608,7 +608,7 @@ class SaveImageLM:
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
# Generate filename with counter if needed
base_filename = filename
base_filename = filename.replace("%batch_num%", str(i))
if add_counter_to_filename:
# Use counter + i to ensure unique filenames for all images in batch
current_counter = counter + i
+32 -15
View File
@@ -123,24 +123,39 @@ class AutomaticMetadataParser(RecipeMetadataParser):
if model_hash_from_hashes:
metadata["model_hash"] = model_hash_from_hashes
# Extract Lora hashes in alternative format
# Extract Lora hashes in alternative format.
# Run unconditionally (not just as fallback) so that
# non-empty hashes from Lora hashes fill in the gaps left
# by empty values in the Hashes JSON dict. Some WebUI
# builds write real hash values only to Lora hashes and
# leave the Hashes JSON values empty.
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
if not hashes_match and lora_hashes_match:
if lora_hashes_match:
try:
lora_hashes_str = lora_hashes_match.group(1)
lora_hash_entries = lora_hashes_str.split(', ')
# Initialize hashes dict if it doesn't exist
if "hashes" not in metadata:
metadata["hashes"] = {}
# Parse each lora hash entry (format: "name: hash")
for entry in lora_hash_entries:
if ': ' in entry:
lora_name, lora_hash = entry.split(': ', 1)
# Add as lora type in the same format as regular hashes
metadata["hashes"][f"lora:{lora_name}"] = lora_hash.strip()
lora_hash = lora_hash.strip()
if not lora_hash:
# Skip entries without a hash value
continue
# Initialize hashes dict if it doesn't exist
if "hashes" not in metadata:
metadata["hashes"] = {}
# Add as lora type in the same format as
# regular hashes. Only override an
# existing entry if its value is empty
# (Lora hashes is the more reliable
# source when Hashes JSON has blanks).
key = f"lora:{lora_name}"
existing = metadata["hashes"].get(key, "")
if not existing:
metadata["hashes"][key] = lora_hash
# Remove lora hashes from params section
params_section = params_section.replace(lora_hashes_match.group(0), '')
except Exception as e:
@@ -362,6 +377,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
# Only process lora or hypernet types
if not hash_key.startswith(("lora:", "hypernet:")):
continue
# Skip entries without a hash value — they can't be
# resolved via CivitAI and would only produce a
# useless "Deleted" entry in the recipe.
if not lora_hash:
continue
lora_type, lora_name = hash_key.split(':', 1)
@@ -387,11 +408,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
# Try to get info from Civitai
if metadata_provider:
try:
if lora_hash:
# If we have hash, use it for lookup
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
else:
civitai_info = None
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
+53 -5
View File
@@ -514,11 +514,21 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
result["loras"].append(lora_entry)
# Process modelVersionIds from Civitai image API
# These are model version IDs returned at root level when meta doesn't contain resources
if "modelVersionIds" in metadata and isinstance(
metadata["modelVersionIds"], list
# Process modelVersionIds from Civitai image API.
# These are version IDs returned at root level of the API response.
# When resources or civitaiResources are already present in metadata
# (which they are when ?withMeta=true is passed), those sections have
# complete hash/type information — modelVersionIds is a fallback for
# when meta is null and only the flat ID list is available. Skipping
# it here avoids duplicates: the same file hash often resolves to
# different version IDs via hash lookup (resources) vs the original
# version ID in modelVersionIds, and both paths would create entries.
if (
"modelVersionIds" in metadata
and isinstance(metadata["modelVersionIds"], list)
and not result.get("loras")
):
for version_id in metadata["modelVersionIds"]:
version_id_str = str(version_id)
@@ -526,6 +536,13 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if version_id_str in added_loras:
continue
# Skip if this version ID is already the recipe's checkpoint
# (resolved earlier from embedded resources/Model hash,
# avoiding a duplicate CivitAI API call).
existing_model = result.get("model")
if existing_model and str(existing_model.get("id")) == version_id_str:
continue
# Initialize lora entry with version ID
lora_entry = {
"id": version_id,
@@ -559,9 +576,40 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
)
if populated_entry is None:
continue # Skip invalid LoRA types
# Not a LoRA — try as checkpoint (only if we
# don't already have one). Reuses the same
# civitai_info from the API call above so no
# extra query is made.
if result["model"] is None:
checkpoint_entry = {
"id": version_id,
"modelId": 0,
"name": "Unknown Model",
"version": "",
"type": "checkpoint",
"existsLocally": False,
"localPath": None,
"file_name": "",
"hash": "",
"thumbnailUrl": (
"/loras_static/images/no-preview.png"
),
"baseModel": "",
"size": 0,
"downloadUrl": "",
"isDeleted": False,
}
cp_populated = await (
self.populate_checkpoint_from_civitai(
checkpoint_entry, civitai_info
)
)
if cp_populated.get("modelId"):
result["model"] = cp_populated
continue # Not a LoRA, don't add to loras
lora_entry = populated_entry
except Exception as e:
logger.error(
f"Error fetching Civitai info for model version {version_id}: {e}"
+167
View File
@@ -0,0 +1,167 @@
"""HTTP route handlers for agent skill endpoints.
These handlers expose the :class:`AgentService` via HTTP, allowing the
frontend to list available skills and execute them on selected models.
Progress is reported via WebSocket broadcast.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Dict
from aiohttp import web
from ...services.agent import AgentService, AgentProgressReporter
from ...services.llm_service import LLMNotConfiguredError
logger = logging.getLogger(__name__)
class AgentHandler:
"""HTTP handler for agent skill operations."""
def __init__(self, agent_service: AgentService | None = None) -> None:
self._agent_service = agent_service
async def _ensure_service(self) -> AgentService:
if self._agent_service is None:
self._agent_service = await AgentService.get_instance()
return self._agent_service
# ------------------------------------------------------------------
# GET /api/lm/agent/skills
# ------------------------------------------------------------------
async def get_agent_skills(self, request: web.Request) -> web.Response:
"""Return a list of available agent skills."""
service = await self._ensure_service()
skills = await service.list_skills()
return web.json_response({"skills": skills})
# ------------------------------------------------------------------
# POST /api/lm/agent/execute/{skill_name}
# ------------------------------------------------------------------
async def execute_agent_skill(self, request: web.Request) -> web.Response:
"""Execute an agent skill on the provided model paths.
Request body::
{"model_paths": ["/path/to/model1.safetensors", ...], "options": {}}
Returns immediately with a task ID. Execution runs in the
background; progress and completion are pushed via WebSocket
events of type ``agent_progress``.
"""
skill_name = request.match_info.get("skill_name", "")
if not skill_name:
return web.json_response(
{"error": "Skill name is required"}, status_code=400
)
try:
body = await request.json()
except Exception:
return web.json_response(
{"error": "Invalid JSON body"}, status_code=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,
)
service = await self._ensure_service()
# Validate LLM configuration early for skills that need it
# (fail fast rather than after starting background work)
try:
from ...services.llm_service import LLMService
llm = await LLMService.get_instance()
if not llm.is_configured():
return web.json_response(
{
"error": "LLM provider is not configured. "
"Enable it in Settings → AI Provider.",
},
status=400,
)
except Exception as exc:
logger.error("Failed to check LLM configuration: %s", exc)
# Launch execution in the background
progress_reporter = AgentProgressReporter()
logger.info(
"Agent skill '%s' starting for %d model(s) in background task",
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,
input_data={"model_paths": model_paths},
progress_callback=progress_reporter,
)
logger.info(
"Agent skill '%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)
await progress_reporter.on_progress(
{
"type": "agent_progress",
"skill": skill_name,
"status": "error",
"error": str(exc),
}
)
except Exception as exc:
logger.error("Agent skill '%s' failed: %s", skill_name, exc, exc_info=True)
await progress_reporter.on_progress(
{
"type": "agent_progress",
"skill": skill_name,
"status": "error",
"error": str(exc),
}
)
# Fire and forget — progress comes via WebSocket
task = asyncio.create_task(_run())
logger.info("Agent skill '%s' background task created (id=%s)", skill_name, task)
return web.json_response(
{
"status": "started",
"skill": skill_name,
"model_count": len(model_paths),
}
)
# ------------------------------------------------------------------
# POST /api/lm/agent/cancel
# ------------------------------------------------------------------
async def cancel_agent_skill(self, request: web.Request) -> web.Response:
"""Cancel a running agent skill.
NOTE: Cancellation is a stub for now — the AgentService processes
models sequentially and does not yet support mid-execution
cancellation. This endpoint exists for API completeness.
"""
# TODO: implement cooperative cancellation in AgentService
return web.json_response(
{"status": "acknowledged", "note": "Cancellation not yet implemented"},
status_code=200,
)
+417
View File
@@ -0,0 +1,417 @@
"""Handlers for Hugging Face model listing and download.
Minimal MVP implementation — uses direct HTTP to the HF API for file
listing and the project's existing aiohttp-based Downloader for
downloading. No huggingface_hub dependency required.
"""
from __future__ import annotations
import json
import logging
import os
import re
from typing import Any
import aiohttp
from aiohttp import web
from ...config import config
from ...services.downloader import (
DownloadProgress,
get_downloader,
)
from ...services.aria2_downloader import Aria2Downloader
from ...services.settings_manager import get_settings_manager
from ...services.service_registry import ServiceRegistry
from ...services.websocket_manager import ws_manager
from ...utils.constants import MODEL_FILE_EXTENSIONS
from ...utils.metadata_manager import MetadataManager
from ...utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
logger = logging.getLogger(__name__)
_DEFAULT_MODEL_CLASS = LoraMetadata
_DEFAULT_SCANNER_GETTER = "get_lora_scanner"
# Shared aiohttp session for HF API calls (created on first use)
_hf_api_session: aiohttp.ClientSession | None = None
async def _get_hf_api_session() -> aiohttp.ClientSession:
"""Get or create the shared aiohttp session for HF API calls."""
global _hf_api_session # needed because we reassign the module-level name
if _hf_api_session is None or _hf_api_session.closed:
_hf_api_session = aiohttp.ClientSession(
headers={"User-Agent": "ComfyUI-LoRA-Manager/1.0"},
timeout=aiohttp.ClientTimeout(total=30),
)
return _hf_api_session
async def close_hf_api_session() -> None:
"""Close the shared HF API session, if it was ever created."""
global _hf_api_session
if _hf_api_session is not None and not _hf_api_session.closed:
await _hf_api_session.close()
_hf_api_session = None
def _infer_model_type(model_root: str) -> tuple[Any, str]:
"""Determine model class and scanner by matching ``model_root`` against the
configured root paths for each model type (from ``Config``).
The ``model_root`` value comes from the frontend's model-root dropdown,
which is populated from the current page's scanner roots. By checking
which scanner's root list it belongs to, we avoid fragile heuristics
like substring-matching path names.
"""
norm = os.path.normpath(model_root).replace(os.sep, "/")
# LoRA roots
for p in (config.loras_roots or []) + (config.extra_loras_roots or []):
if os.path.normpath(p).replace(os.sep, "/") == norm:
return LoraMetadata, "get_lora_scanner"
# Checkpoint / UNet roots
for p in (
(config.checkpoints_roots or [])
+ (config.extra_checkpoints_roots or [])
+ (config.unet_roots or [])
+ (config.extra_unet_roots or [])
):
if os.path.normpath(p).replace(os.sep, "/") == norm:
return CheckpointMetadata, "get_checkpoint_scanner"
# Embedding roots
for p in (config.embeddings_roots or []) + (config.extra_embeddings_roots or []):
if os.path.normpath(p).replace(os.sep, "/") == norm:
return EmbeddingMetadata, "get_embedding_scanner"
# Fallback — should not happen in normal use
logger.warning(
"Could not determine model type for root '%s'; defaulting to LoRA",
model_root,
)
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER
async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
"""Create a proper .metadata.json and add the model to the scanner cache.
Uses ``MetadataManager.create_default_metadata()`` which computes the
SHA256 hash, extracts safetensors header metadata (base_model), and
produces a fully-populated ``LoraMetadata`` (or ``CheckpointMetadata`` /
``EmbeddingMetadata``) object. We then overlay HF-specific fields and
register the model in the in-memory scanner cache so it appears
immediately without a full filesystem walk.
"""
try:
hf_url = f"https://huggingface.co/{repo}"
model_class, scanner_getter_name = _infer_model_type(model_root)
# 1. Create proper metadata (computes SHA256, reads safetensors headers)
metadata = await MetadataManager.create_default_metadata(
dest_path, model_class=model_class
)
if metadata is None:
logger.warning("create_default_metadata returned None for %s", dest_path)
return
# 2. Overlay HF-specific fields
metadata._unknown_fields["hf_url"] = hf_url
metadata.from_civitai = False # HF models are not from CivitAI
# 3. Save metadata atomically
await MetadataManager.save_metadata(dest_path, metadata)
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
# 4. Determine relative folder path for cache
# model_root is an absolute path; dest_path is under it
folder = ""
if os.path.isabs(model_root) and dest_path.startswith(model_root):
rel = os.path.relpath(os.path.dirname(dest_path), model_root)
folder = rel.replace(os.sep, "/") if rel != "." else ""
# 5. Add to scanner cache (same as CivitAI's _execute_download does)
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
if scanner_getter is not None:
scanner = await scanner_getter()
if scanner is not None:
metadata_dict = metadata.to_dict()
metadata_dict["hf_url"] = hf_url
await scanner.add_model_to_cache(metadata_dict, folder)
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
except Exception as exc:
logger.warning("Failed to save HF metadata for %s: %s", dest_path, exc)
class HfHandler:
"""Handle Hugging Face model browsing and download."""
async def get_hf_repo_files(self, request: web.Request) -> web.Response:
"""List model-weight files from a HF repo with real file sizes.
Uses the HF tree API endpoint which returns accurate file sizes
(including LFS-tracked files), unlike the model info endpoint.
"""
repo = request.query.get("repo", "").strip()
if not repo or "/" not in repo:
return web.json_response(
{"error": "Missing or invalid 'repo' parameter (expected user/repo)"},
status=400,
)
url = f"https://huggingface.co/api/models/{repo}/tree/main"
try:
session = await _get_hf_api_session()
async with session.get(url) as resp:
if resp.status == 404:
return web.json_response(
{"error": f"Repo '{repo}' not found"}, status=404
)
if resp.status != 200:
text = await resp.text()
return web.json_response(
{"error": f"HF API error {resp.status}: {text[:200]}"},
status=resp.status,
)
tree: list[dict[str, Any]] = await resp.json()
except Exception as exc:
logger.error("Failed to fetch HF repo files: %s", exc)
return web.json_response({"error": str(exc)}, status=502)
files: list[dict[str, Any]] = []
for entry in tree:
path: str = entry.get("path", "")
ext = os.path.splitext(path)[1].lower()
if ext not in MODEL_FILE_EXTENSIONS:
continue
size = entry.get("size", 0) or 0
if size == 0 and "lfs" in entry:
size = entry["lfs"].get("size", 0) or 0
files.append({
"filename": path,
"size": size,
})
files.sort(key=lambda f: f["size"], reverse=True)
return web.json_response(files)
async def download_hf_model(self, request: web.Request) -> web.Response:
"""Download a single file from Hugging Face into the model directory.
POST JSON body::
{
"repo": "dx8152/Flux2-Klein-9B-Consistency",
"filename": "Flux2-Klein-9B-consistency-V2.safetensors",
"revision": "main",
"model_root": "loras",
"relative_path": "",
"use_default_paths": false,
"download_id": "optional-batch-id"
}
If ``download_id`` is provided, real-time progress (bytes, speed,
percentage) is broadcast via the WebSocket progress system, matching
the CivitAI download experience.
Respects the ``download_backend`` setting (``aria2`` or ``default``).
"""
try:
payload: dict[str, Any] = await request.json()
except json.JSONDecodeError:
return web.json_response({"error": "Invalid JSON"}, status=400)
repo = (payload.get("repo") or "").strip()
filename = (payload.get("filename") or "").strip()
revision = (payload.get("revision") or "main").strip()
model_root = (payload.get("model_root") or "").strip()
relative_path = (payload.get("relative_path") or "").strip()
use_default_paths = bool(payload.get("use_default_paths", False))
download_id: str | None = payload.get("download_id")
logger.info(
"download_hf_model: repo=%s file=%s root=%s download_id=%s",
repo, filename, model_root, download_id,
)
if not repo or not filename:
return web.json_response(
{"error": "Missing required fields: 'repo' and 'filename'"}, status=400
)
# Validate repo format — must be user/repo_name
if repo.count("/") != 1 or not re.match(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", repo):
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
author, repo_name = repo.split("/", 1)
if ".." in (author, repo_name) or "." in (author, repo_name):
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
# Validate filename — must not contain path separators or ..
if "/" in filename or "\\" in filename or ".." in filename:
return web.json_response({"error": "Invalid filename"}, status=400)
# Validate relative_path — must not be absolute or escape base directory
if relative_path:
if os.path.isabs(relative_path):
return web.json_response({"error": "relative_path must not be absolute"}, status=400)
if ".." in relative_path.split("/") or "\\" in relative_path:
return web.json_response({"error": "Invalid relative_path"}, status=400)
# 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)
)
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
if use_default_paths:
target_dir = os.path.join(base_dir, "huggingface", author, repo_name)
elif relative_path:
target_dir = os.path.join(base_dir, relative_path)
else:
target_dir = base_dir
os.makedirs(target_dir, exist_ok=True)
dest_path = os.path.join(target_dir, 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)
# Check if already exists (simple skip)
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
logger.info("download_hf_model: file already exists, skipping — %s", dest_path)
return web.json_response({
"success": True,
"message": f"File already exists: {dest_path}",
"path": dest_path,
})
# Build HF resolve URL
resolve_url = (
f"https://huggingface.co/{repo}/resolve/{revision}/{filename}"
)
# Set up progress callback if download_id is provided
progress_callback = None
if download_id:
async def _progress_callback(
progress: float | DownloadProgress,
snapshot: DownloadProgress | None = None,
) -> None:
percent = 0.0
metrics = snapshot if isinstance(snapshot, DownloadProgress) else None
if isinstance(progress, DownloadProgress):
percent = progress.percent_complete
metrics = progress
elif isinstance(snapshot, DownloadProgress):
percent = snapshot.percent_complete
else:
percent = float(progress)
broadcast: dict[str, Any] = {
"status": "progress",
"progress": round(percent),
}
if metrics:
broadcast["bytes_downloaded"] = metrics.bytes_downloaded
broadcast["total_bytes"] = metrics.total_bytes
broadcast["bytes_per_second"] = metrics.bytes_per_second
await ws_manager.broadcast_download_progress(download_id, broadcast)
progress_callback = _progress_callback
# Respect download backend setting (aria2 vs default)
download_backend = (
get_settings_manager().get("download_backend", "default")
)
if download_backend == "aria2":
aria2 = await Aria2Downloader.get_instance()
aid = download_id or f"hf_{repo}_{filename}"
try:
hf_success, hf_result = await aria2.download_file(
url=resolve_url,
save_path=dest_path,
download_id=aid,
progress_callback=progress_callback,
)
if hf_success:
await _save_hf_metadata(dest_path, repo, model_root)
return web.json_response({
"success": True,
"message": f"Downloaded to {dest_path}",
"path": dest_path,
})
else:
return web.json_response(
{"success": False, "error": hf_result or "aria2 download failed"},
status=500,
)
except Exception as exc:
logger.error("HF download (aria2) failed: %s", exc)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
# Default: use built-in aiohttp Downloader
downloader = await get_downloader()
try:
success, result = await downloader.download_file(
url=resolve_url,
save_path=dest_path,
use_auth=False,
allow_resume=True,
progress_callback=progress_callback,
)
if success:
await _save_hf_metadata(dest_path, repo, model_root)
return web.json_response({
"success": True,
"message": f"Downloaded to {result}",
"path": result,
})
else:
return web.json_response(
{"success": False, "error": result or "Download failed"},
status=500,
)
except Exception as exc:
logger.error("HF download failed: %s", exc)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
+198 -83
View File
@@ -48,6 +48,8 @@ from ...utils.constants import (
SUPPORTED_MEDIA_EXTENSIONS,
VALID_LORA_TYPES,
)
from .hf_handlers import HfHandler
from .agent_handlers import AgentHandler
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import (
find_non_compliant_items_in_example_images_root,
@@ -414,9 +416,10 @@ class PromptServerProtocol(Protocol):
"""Subset of PromptServer used by the handlers."""
instance: "PromptServerProtocol"
sockets: dict # maps clientId (sid) → WebSocketResponse
def send_sync(
self, event: str, payload: dict
self, event: str, payload: dict | None = None, sid: str | None = None
) -> None: # pragma: no cover - protocol
...
@@ -471,89 +474,154 @@ class BackupServiceProtocol(Protocol):
class NodeRegistry:
"""Thread-safe registry for tracking LoRA nodes in active workflows."""
"""Thread-safe registry for tracking LoRA nodes across ComfyUI tabs.
Each connected ComfyUI browser tab (identified by its ``sid`` / ``clientId``)
registers its own set of workflow nodes. Queries merge all known tabs into
a single result so that the calling LM panel always sees *every* available
target node, regardless of which tab responded fastest.
"""
def __init__(self) -> None:
self._lock = asyncio.Lock()
self._nodes: Dict[str, dict] = {}
self._registry_updated = asyncio.Event()
# sid → {unique_id → node_info}
self._tab_nodes: Dict[str, Dict[str, dict]] = {}
self._ready = asyncio.Event()
self._waiting_clients: set[str] = set()
@property
def pending_client_count(self) -> int:
"""Number of clients that have not yet responded in the current refresh cycle."""
return len(self._waiting_clients)
# ------------------------------------------------------------------
# Helpers to build one node dict (extracted so it's reused for each tab)
# ------------------------------------------------------------------
@staticmethod
def _build_node_dict(node: dict) -> dict:
node_id = node["node_id"]
graph_id = str(node["graph_id"])
unique_id = f"{graph_id}:{node_id}"
node_type = node.get("type", "")
type_id = NODE_TYPES.get(node_type, 0)
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
raw_capabilities = node.get("capabilities")
capabilities: dict = {}
if isinstance(raw_capabilities, dict):
capabilities = dict(raw_capabilities)
raw_widget_names: list | None = node.get("widget_names")
if not isinstance(raw_widget_names, list):
capability_widget_names = capabilities.get("widget_names")
raw_widget_names = (
capability_widget_names
if isinstance(capability_widget_names, list)
else None
)
widget_names: list[str] = []
if isinstance(raw_widget_names, list):
widget_names = [
str(widget_name)
for widget_name in raw_widget_names
if isinstance(widget_name, str) and widget_name
]
if widget_names:
capabilities["widget_names"] = widget_names
else:
capabilities.pop("widget_names", None)
if "supports_lora" in capabilities:
capabilities["supports_lora"] = bool(capabilities["supports_lora"])
comfy_class = node.get("comfy_class")
if not isinstance(comfy_class, str) or not comfy_class:
comfy_class = node_type if isinstance(node_type, str) else None
return {
"id": node_id,
"graph_id": graph_id,
"graph_name": node.get("graph_name"),
"unique_id": unique_id,
"bgcolor": bgcolor,
"title": node.get("title"),
"type": type_id,
"type_name": node_type,
"comfy_class": comfy_class,
"capabilities": capabilities,
"widget_names": widget_names,
"mode": node.get("mode"),
"marker_role": node.get("marker_role"),
}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def register_nodes(self, sid: str, nodes: list[dict]) -> None:
"""Register/replace the node list for a single ComfyUI tab (identified by *sid*)."""
tab_nodes: dict[str, dict] = {}
for node in nodes:
nd = self._build_node_dict(node)
tab_nodes[nd["unique_id"]] = nd
async def register_nodes(self, nodes: list[dict]) -> None:
async with self._lock:
self._nodes.clear()
for node in nodes:
node_id = node["node_id"]
graph_id = str(node["graph_id"])
unique_id = f"{graph_id}:{node_id}"
node_type = node.get("type", "")
type_id = NODE_TYPES.get(node_type, 0)
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
raw_capabilities = node.get("capabilities")
capabilities: dict = {}
if isinstance(raw_capabilities, dict):
capabilities = dict(raw_capabilities)
self._tab_nodes[sid] = tab_nodes
self._waiting_clients.discard(sid)
if not self._waiting_clients:
self._ready.set()
raw_widget_names: list | None = node.get("widget_names")
if not isinstance(raw_widget_names, list):
capability_widget_names = capabilities.get("widget_names")
raw_widget_names = (
capability_widget_names
if isinstance(capability_widget_names, list)
else None
)
logger.debug("Registered %s nodes from client %s", len(nodes), sid)
widget_names: list[str] = []
if isinstance(raw_widget_names, list):
widget_names = [
str(widget_name)
for widget_name in raw_widget_names
if isinstance(widget_name, str) and widget_name
]
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."""
self._ready.clear()
self._waiting_clients = set(active_sids)
if widget_names:
capabilities["widget_names"] = widget_names
else:
capabilities.pop("widget_names", None)
if "supports_lora" in capabilities:
capabilities["supports_lora"] = bool(capabilities["supports_lora"])
comfy_class = node.get("comfy_class")
if not isinstance(comfy_class, str) or not comfy_class:
comfy_class = node_type if isinstance(node_type, str) else None
self._nodes[unique_id] = {
"id": node_id,
"graph_id": graph_id,
"graph_name": node.get("graph_name"),
"unique_id": unique_id,
"bgcolor": bgcolor,
"title": node.get("title"),
"type": type_id,
"type_name": node_type,
"comfy_class": comfy_class,
"capabilities": capabilities,
"widget_names": widget_names,
"mode": node.get("mode"),
}
logger.debug("Registered %s nodes in registry", len(nodes))
self._registry_updated.set()
async def get_registry(self) -> dict:
async with self._lock:
return {
"nodes": dict(self._nodes),
"node_count": len(self._nodes),
}
async def wait_for_update(self, timeout: float = 1.0) -> bool:
self._registry_updated.clear()
async def wait_for_all(self, timeout: float = 2.0) -> bool:
"""Block until every client in the current waiting set has responded
(or *timeout* seconds elapse). Returns ``True`` if all responded."""
if not self._waiting_clients:
return True
try:
await asyncio.wait_for(self._registry_updated.wait(), timeout=timeout)
await asyncio.wait_for(self._ready.wait(), timeout=timeout)
return True
except asyncio.TimeoutError:
return False
async def get_merged_registry(self, active_sids: set[str] | None = None) -> dict:
"""Return the union of all known tab nodes, pruning any tab that is no
longer connected."""
async with self._lock:
# Garbage-collect stale entries (disconnected tabs)
if active_sids is not None:
for sid in list(self._tab_nodes):
if sid not in active_sids:
del self._tab_nodes[sid]
merged: dict[str, dict] = {}
tab_info: dict[str, dict] = {}
for sid, nodes in self._tab_nodes.items():
tab_info[sid] = {
"node_count": len(nodes),
"graph_names": list(
{
n.get("graph_name")
for n in nodes.values()
if n.get("graph_name")
}
),
}
merged.update(nodes)
return {
"nodes": merged,
"node_count": len(merged),
"tab_count": len(self._tab_nodes),
"tabs": tab_info,
}
class HealthCheckHandler:
async def health_check(self, request: web.Request) -> web.Response:
@@ -2994,10 +3062,21 @@ class NodeRegistryHandler:
try:
data = await request.json()
nodes = data.get("nodes", [])
client_id = data.get("client_id")
if not isinstance(nodes, list):
return web.json_response(
{"success": False, "error": "nodes must be a list"}, status=400
)
if not isinstance(client_id, str) or not client_id:
return web.json_response(
{
"success": False,
"error": "Missing client_id parameter",
},
status=400,
)
for index, node in enumerate(nodes):
if not isinstance(node, dict):
return web.json_response(
@@ -3041,7 +3120,7 @@ class NodeRegistryHandler:
else:
node["graph_name"] = str(graph_name)
await self._node_registry.register_nodes(nodes)
await self._node_registry.register_nodes(client_id, nodes)
return web.json_response(
{
"success": True,
@@ -3065,9 +3144,15 @@ 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 to frontend")
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(
@@ -3079,19 +3164,31 @@ class NodeRegistryHandler:
status=500,
)
registry_updated = await self._node_registry.wait_for_update(timeout=1.0)
if not registry_updated:
logger.warning("Registry refresh timeout after 1 second")
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())
registry_info = await self._node_registry.get_merged_registry(
active_sids=current_sids
)
if registry_info["node_count"] == 0:
logger.warning("No nodes registered after refresh")
return web.json_response(
{
"success": False,
"error": "Timeout Error",
"message": "Registry refresh timeout - ComfyUI frontend may not be responsive",
"error": "Empty Registry",
"message": "No workflow nodes found — ensure ComfyUI is open and the extension is loaded.",
},
status=408,
)
registry_info = await self._node_registry.get_registry()
return web.json_response({"success": True, "data": registry_info})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to get registry: %s", exc, exc_info=True)
@@ -3104,13 +3201,17 @@ class NodeRegistryHandler:
try:
data = await request.json()
widget_name = data.get("widget_name")
action = data.get("action")
value = data.get("value")
mode = data.get("mode", "replace")
node_ids = data.get("node_ids")
if not isinstance(widget_name, str) or not widget_name:
if not action and (not isinstance(widget_name, str) or not widget_name):
return web.json_response(
{"success": False, "error": "Missing widget_name parameter"},
{
"success": False,
"error": "Missing parameter: provide either 'action' or 'widget_name'",
},
status=400,
)
@@ -3149,12 +3250,15 @@ class NodeRegistryHandler:
except (TypeError, ValueError):
parsed_node_id = node_identifier
payload = {
payload: dict = {
"id": parsed_node_id,
"widget_name": widget_name,
"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)
@@ -3213,6 +3317,8 @@ class MiscHandlerSet:
doctor: DoctorHandler,
example_workflows: ExampleWorkflowsHandler,
base_model: BaseModelHandlerSet,
hf_handler: HfHandler | None = None,
agent_handler: AgentHandler | None = None,
) -> None:
self.health = health
self.settings = settings
@@ -3231,6 +3337,8 @@ class MiscHandlerSet:
self.doctor = doctor
self.example_workflows = example_workflows
self.base_model = base_model
self.hf_handler = hf_handler
self.agent_handler = agent_handler
def to_route_mapping(
self,
@@ -3276,6 +3384,13 @@ class MiscHandlerSet:
"get_supporters": self.supporters.get_supporters,
"get_example_workflows": self.example_workflows.get_example_workflows,
"get_example_workflow": self.example_workflows.get_example_workflow,
# Hugging Face handlers
"get_hf_repo_files": self.hf_handler.get_hf_repo_files,
"download_hf_model": self.hf_handler.download_hf_model,
# Agent skill handlers
"get_agent_skills": self.agent_handler.get_agent_skills,
"execute_agent_skill": self.agent_handler.execute_agent_skill,
"cancel_agent_skill": self.agent_handler.cancel_agent_skill,
# Base model handlers
"get_base_models": self.base_model.get_base_models,
"refresh_base_models": self.base_model.refresh_base_models,
+52 -18
View File
@@ -203,11 +203,17 @@ class ModelListingHandler:
result = await self._service.get_paginated_data(**params)
format_start = time.perf_counter()
formatted_raw = [
await self._service.format_response(entry)
for entry in result["items"]
]
# Filter out None entries returned for corrupted cache rows (issue #730).
# Note: "total" intentionally remains the pre-filter count to reflect
# the true number of models in the cache; corrupted entries are rare
# and adjusting total would cause pagination drift on every page.
formatted_items = [item for item in formatted_raw if item is not None]
formatted_result = {
"items": [
await self._service.format_response(item)
for item in result["items"]
],
"items": formatted_items,
"total": result["total"],
"page": result["page"],
"page_size": result["page_size"],
@@ -233,14 +239,20 @@ class ModelListingHandler:
start_time = time.perf_counter()
try:
params = self._parse_common_params(request)
# group_by_model is meaningless for excluded view; strip it
params.pop("group_by_model", None)
result = await self._service.get_excluded_paginated_data(**params)
format_start = time.perf_counter()
formatted_raw = [
await self._service.format_response(entry)
for entry in result["items"]
]
# Filter out None entries returned for corrupted cache rows (issue #730).
# "total" stays at the pre-filter count; see get_models for rationale.
formatted_items = [item for item in formatted_raw if item is not None]
formatted_result = {
"items": [
await self._service.format_response(item)
for item in result["items"]
],
"items": formatted_items,
"total": result["total"],
"page": result["page"],
"page_size": result["page_size"],
@@ -366,6 +378,19 @@ class ModelListingHandler:
request.query.get("name_pattern_use_regex", "false").lower() == "true"
)
# Group-by-model flag: deduplicate versions sharing the same civitai modelId
group_by_model = (
request.query.get("group_by_model", "false").lower() == "true"
)
# View-local-versions filter: show all local versions of a specific model
civitai_model_id = request.query.get("civitai_model_id")
if civitai_model_id is not None:
try:
civitai_model_id = int(civitai_model_id)
except (TypeError, ValueError):
civitai_model_id = None
return {
"page": page,
"page_size": page_size,
@@ -389,6 +414,8 @@ class ModelListingHandler:
"name_pattern_include": name_pattern_include,
"name_pattern_exclude": name_pattern_exclude,
"name_pattern_use_regex": name_pattern_use_regex,
"group_by_model": group_by_model,
"civitai_model_id": civitai_model_id,
**self._parse_specific_params(request),
}
@@ -516,8 +543,13 @@ class ModelManagementHandler:
if not success:
return web.json_response({"success": False, "error": error})
formatted_metadata = await self._service.format_response(model_data)
return web.json_response({"success": True, "metadata": formatted_metadata})
formatted = await self._service.format_response(model_data)
if formatted is None:
return web.json_response(
{"success": False, "error": "Model entry is corrupted (missing file_path)"},
status=500,
)
return web.json_response({"success": True, "metadata": formatted})
except Exception as exc:
if is_expected_offline_error(str(exc)):
return web.json_response(
@@ -1074,10 +1106,12 @@ class ModelQueryHandler:
# Sort: originals first, copies last
sorted_models = self._sort_duplicate_group(filtered)
# Format response
# Format response, filtering out corrupted entries (issue #730)
group = {"hash": sha256, "models": []}
for model in sorted_models:
group["models"].append(await self._service.format_response(model))
formatted = await self._service.format_response(model)
if formatted is not None:
group["models"].append(formatted)
# Only include groups with 2+ models after filtering
if len(group["models"]) > 1:
@@ -1194,9 +1228,9 @@ class ModelQueryHandler:
(m for m in cache.raw_data if m["file_path"] == path), None
)
if model:
group["models"].append(
await self._service.format_response(model)
)
formatted = await self._service.format_response(model)
if formatted is not None:
group["models"].append(formatted)
hash_val = self._service.scanner.get_hash_by_filename(filename)
if hash_val:
main_path = self._service.get_path_by_hash(hash_val)
@@ -1206,9 +1240,9 @@ class ModelQueryHandler:
None,
)
if main_model:
group["models"].insert(
0, await self._service.format_response(main_model)
)
formatted = await self._service.format_response(main_model)
if formatted is not None:
group["models"].insert(0, formatted)
if group["models"]:
result.append(group)
return web.json_response(
+40 -2
View File
@@ -32,6 +32,7 @@ from ...utils.civitai_utils import (
extract_civitai_image_id_from_cdn_url,
rewrite_preview_url,
)
from ...utils.constants import NSFW_LEVELS
from ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher
@@ -1120,6 +1121,13 @@ class RecipeManagementHandler:
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
metadata["base_model"] = parsed_embedded["base_model"]
# Extract preview_nsfw_level from the CivitAI API response
# (injected into civitai_meta_raw by _download_remote_media).
if isinstance(civitai_meta_raw, dict):
bl = civitai_meta_raw.get("browsingLevel")
if isinstance(bl, int) and bl > 0:
metadata["preview_nsfw_level"] = bl
civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe(
recipe=metadata,
@@ -1515,8 +1523,31 @@ class RecipeManagementHandler:
# CivitAI API returns modelVersionIds at the root level of
# the image response, NOT inside the meta object.
mvids = image_info.get("modelVersionIds")
if mvids and isinstance(civitai_meta_raw, dict):
civitai_meta_raw["modelVersionIds"] = mvids
if mvids:
if isinstance(civitai_meta_raw, dict):
civitai_meta_raw["modelVersionIds"] = mvids
else:
# meta is null but modelVersionIds exists — create a
# minimal dict so downstream parsers can discover
# LoRAs and checkpoints from the API response.
civitai_meta_raw = {"modelVersionIds": mvids}
# Inject browsingLevel (canonical integer) so the recipe's
# preview_nsfw_level can be set, enabling proper NSFW blur
# of the preview image. Fall back to nsfwLevel (string)
# when browsingLevel is absent.
if isinstance(civitai_meta_raw, dict):
browsing_level = image_info.get("browsingLevel")
nsfw_level_str = image_info.get("nsfwLevel")
if isinstance(browsing_level, int) and browsing_level > 0:
civitai_meta_raw["browsingLevel"] = browsing_level
elif (
isinstance(nsfw_level_str, str)
and nsfw_level_str in NSFW_LEVELS
):
civitai_meta_raw["browsingLevel"] = NSFW_LEVELS[
nsfw_level_str
]
original_url = (
image_info.get("url") if civitai_image_id and image_info else None
@@ -1796,6 +1827,13 @@ class RecipeManagementHandler:
"source_path": image_url,
}
# Extract preview_nsfw_level from the CivitAI API response
# (injected into civitai_meta_raw by _download_remote_media).
if isinstance(civitai_meta_raw, dict):
bl = civitai_meta_raw.get("browsingLevel")
if isinstance(bl, int) and bl > 0:
metadata["preview_nsfw_level"] = bl
if civitai_parsed:
civitai_loras = civitai_parsed.get("loras", [])
if civitai_loras and not metadata.get("loras"):
+17
View File
@@ -94,6 +94,23 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"GET", "/api/lm/delete-model-version", "delete_model_version"
),
# Hugging Face model endpoints
RouteDefinition(
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
),
RouteDefinition(
"POST", "/api/lm/download-hf-model", "download_hf_model"
),
# Agent skill endpoints
RouteDefinition(
"GET", "/api/lm/agent/skills", "get_agent_skills"
),
RouteDefinition(
"POST", "/api/lm/agent/execute/{skill_name}", "execute_agent_skill"
),
RouteDefinition(
"POST", "/api/lm/agent/cancel", "cancel_agent_skill"
),
)
+6
View File
@@ -39,6 +39,8 @@ from .handlers.misc_handlers import (
build_service_registry_adapter,
)
from .handlers.base_model_handlers import BaseModelHandlerSet
from .handlers.hf_handlers import HfHandler
from .handlers.agent_handlers import AgentHandler
from .misc_route_registrar import MiscRouteRegistrar
logger = logging.getLogger(__name__)
@@ -136,6 +138,8 @@ class MiscRoutes:
doctor = DoctorHandler(settings_service=self._settings)
example_workflows = ExampleWorkflowsHandler()
base_model = BaseModelHandlerSet()
hf_handler = HfHandler()
agent_handler = AgentHandler()
return self._handler_set_factory(
health=health,
@@ -155,6 +159,8 @@ class MiscRoutes:
doctor=doctor,
example_workflows=example_workflows,
base_model=base_model,
hf_handler=hf_handler,
agent_handler=agent_handler,
)
+29 -4
View File
@@ -16,6 +16,27 @@ logger = logging.getLogger(__name__)
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
# User-managed directories that live inside the plugin folder (portable
# mode) and must survive a Git-based update. ``git clean -fd`` would
# otherwise delete them because they are untracked and, in released tags,
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
# regardless of whether it is ignored.
_PRESERVE_DIRS = ('settings.json', 'civitai', 'wildcards', 'backups', 'stats', 'logs', 'cache', 'model_cache')
def _clean_excludes() -> List[str]:
"""Build the ``-e`` arguments for ``git clean`` from :data:`_PRESERVE_DIRS`."""
excludes: List[str] = []
for name in _PRESERVE_DIRS:
excludes.append('-e')
excludes.append(name)
# For directories, also exclude nested matches explicitly
# (``-e dir`` alone matches the dir entry; ``-e dir/**`` guards
# contents under all git versions as defense-in-depth).
excludes.append('-e')
excludes.append(f'{name}/**')
return excludes
class UpdateRoutes:
"""Routes for handling plugin update checks"""
@@ -365,6 +386,8 @@ class UpdateRoutes:
)
return False, ""
clean_excludes = _clean_excludes()
try:
# Open the Git repository
repo = git.Repo(plugin_root)
@@ -376,8 +399,9 @@ class UpdateRoutes:
if nightly:
# Reset to discard any local changes
repo.git.reset('--hard')
# Clean untracked files
repo.git.clean('-fd')
# Clean untracked files, but preserve user-managed directories
# (wildcards, backups, stats, civitai, caches, settings.json).
repo.git.clean('-fd', *clean_excludes)
# Switch to main branch and pull latest
main_branch = 'main'
@@ -394,8 +418,9 @@ class UpdateRoutes:
else:
# Reset to discard any local changes
repo.git.reset('--hard')
# Clean untracked files
repo.git.clean('-fd')
# Clean untracked files, but preserve user-managed directories
# (wildcards, backups, stats, civitai, caches, settings.json).
repo.git.clean('-fd', *clean_excludes)
# Get latest release tag
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
+23
View File
@@ -0,0 +1,23 @@
"""Agent-powered skill system for LoRA Manager.
This package provides the orchestration layer for LLM/agent-powered features.
Skills define *what* to do (prompt template). The :class:`AgentService`
handles *how* (LLM calls, context gathering, validation, progress).
"""
from __future__ import annotations
from .skill_definition import SkillDefinition, SkillPermissions
from .skill_registry import SkillRegistry
from .agent_service import AgentService, AgentProgressReporter, SkillResult
from .post_processor import PostProcessor
__all__ = [
"AgentProgressReporter",
"AgentService",
"PostProcessor",
"SkillDefinition",
"SkillPermissions",
"SkillRegistry",
"SkillResult",
]
+413
View File
@@ -0,0 +1,413 @@
"""Agent orchestration service.
The :class:`AgentService` coordinates skill 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)
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`)
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).
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import aiohttp
from ..llm_service import LLMService
from ..websocket_manager import ws_manager
from .post_processor import PostProcessor
from .skill_registry import SkillRegistry
logger = logging.getLogger(__name__)
class AgentProgressReporter:
"""Protocol-compatible progress reporter backed by WebSocket broadcast."""
async def on_progress(self, payload: Dict[str, Any]) -> None:
await ws_manager.broadcast(payload)
@dataclass
class SkillResult:
"""Outcome of a skill execution."""
success: bool
updated_models: List[Dict[str, Any]] = field(default_factory=list)
errors: List[str] = field(default_factory=list)
summary: str = ""
def _validate_schema(data: Any, schema: Dict[str, Any], path: str = "") -> List[str]:
"""Minimal JSON schema validator.
Supports a subset of JSON Schema: ``type``, ``properties``, ``required``,
``items``, ``enum``. Returns a list of error messages (empty = valid).
"""
errors: List[str] = []
if not schema:
return errors
expected_type = schema.get("type")
if expected_type:
type_map = {
"string": str,
"number": (int, float),
"integer": int,
"boolean": bool,
"array": list,
"object": dict,
"null": type(None),
}
expected_py = type_map.get(expected_type)
if expected_py is not None and not isinstance(data, expected_py):
errors.append(f"{path or 'root'}: expected {expected_type}, got {type(data).__name__}")
return errors
if expected_type == "object" and isinstance(data, dict):
properties = schema.get("properties", {})
required = schema.get("required", [])
for req_key in required:
if req_key not in data:
errors.append(f"{path or 'root'}: missing required property '{req_key}'")
for key, value in data.items():
if key in properties:
errors.extend(_validate_schema(value, properties[key], f"{path}.{key}"))
if expected_type == "array" and isinstance(data, list):
items_schema = schema.get("items")
if items_schema:
for i, item in enumerate(data):
errors.extend(_validate_schema(item, items_schema, f"{path}[{i}]"))
if "enum" in schema and data not in schema["enum"]:
errors.append(f"{path or 'root'}: value '{data}' not in enum {schema['enum']}")
return errors
# ------------------------------------------------------------------
# Prompt template rendering
# ------------------------------------------------------------------
def _render_prompt(template: str, variables: Dict[str, Any]) -> str:
"""Render a prompt template with ``{{variable}}`` placeholders.
Uses simple regex substitution no Jinja2 dependency needed.
"""
def replace(match: re.Match) -> str:
key = match.group(1).strip()
value = variables.get(key, "")
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, indent=2)
return str(value)
return re.sub(r"\{\{(\w+)\}\}", replace, template)
class AgentService:
"""Orchestrate agent skill execution.
Usage::
service = await AgentService.get_instance()
result = await service.execute_skill(
skill_name="enrich_hf_metadata",
input_data={"model_paths": ["/path/to/model.safetensors"]},
progress_callback=AgentProgressReporter(),
)
"""
_instance: Optional["AgentService"] = None
_lock: asyncio.Lock = asyncio.Lock()
def __init__(
self,
*,
skill_registry: Optional[SkillRegistry] = None,
llm_service: Optional[LLMService] = None,
) -> None:
self._registry = skill_registry
self._llm_service = llm_service
@classmethod
async def get_instance(cls) -> "AgentService":
"""Return the lazily-initialised global ``AgentService``."""
if cls._instance is None:
async with cls._lock:
if cls._instance is None:
cls._instance = cls(
skill_registry=await SkillRegistry.get_instance(),
llm_service=await LLMService.get_instance(),
)
return cls._instance
@classmethod
def reset_instance(cls) -> None:
"""Reset the cached singleton — primarily for tests."""
cls._instance = None
async def _ensure_registry(self) -> SkillRegistry:
if self._registry is None:
self._registry = await SkillRegistry.get_instance()
return self._registry
async def _ensure_llm(self) -> LLMService:
if self._llm_service is None:
self._llm_service = await LLMService.get_instance()
return self._llm_service
async def list_skills(self) -> List[Dict[str, Any]]:
"""Return a JSON-serialisable list of available skills."""
registry = await self._ensure_registry()
return [
{
"name": s.name,
"title": s.title,
"description": s.description,
"llm_required": s.llm_required,
"model_type_filter": s.model_type_filter,
}
for s in registry.list_skills()
]
async def execute_skill(
self,
*,
skill_name: str,
input_data: Dict[str, Any],
progress_callback: Optional[AgentProgressReporter] = None,
) -> SkillResult:
"""Execute an agent skill.
Args:
skill_name: Name of the skill to execute
input_data: Input validated against the skill's ``input_schema``
progress_callback: Optional WebSocket progress reporter
Returns:
:class:`SkillResult` with success status and updated model info
"""
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(
success=False,
errors=[f"Skill not found: {skill_name}"],
summary=f"Skill '{skill_name}' does not exist",
)
input_errors = _validate_schema(input_data, skill.input_schema)
if input_errors:
return SkillResult(
success=False,
errors=input_errors,
summary=f"Invalid input: {'; '.join(input_errors)}",
)
model_paths = input_data.get("model_paths", [])
if not model_paths:
return SkillResult(
success=False,
errors=["No model_paths provided"],
summary="No models to process",
)
total = len(model_paths)
processed = 0
success_count = 0
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,
)
llm = await self._ensure_llm()
llm_configured = llm.is_configured() if skill.llm_required else True
for model_path in model_paths:
logger.info(
"execute_skill '%s': processing model %d/%d: %s",
skill_name, processed + 1, total, model_path,
)
try:
from ...agent_cli 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)
logger.info(
"execute_skill '%s': LLM call for %s (prompt=%d chars)",
skill_name, model_path, len(rendered),
)
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,
)
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")])
)
except Exception as exc:
logger.error("Skill %s failed for %s: %s", skill_name, model_path, exc)
errors.append(f"{model_path}: {exc}")
processed += 1
await self._emit_progress(
progress_callback, skill_name, status="processing",
total=total, processed=processed, success=success_count,
current_path=model_path,
)
result = SkillResult(
success=success_count > 0,
updated_models=updated_models,
errors=errors,
summary=f"Processed {processed}/{total} models, {success_count} succeeded",
)
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,
updated_models=updated_models, errors=errors, summary=result.summary,
)
return result
async def _build_prompt_context(
self,
skill_name: str,
model_path: str,
metadata: Dict[str, Any],
registry: SkillRegistry,
llm: Any,
) -> Dict[str, Any]:
"""Gather variables for the skill's prompt template.
Reads metadata, fetches the HF README (if applicable), lists available
base models, and returns a dict that maps to ``{{variable}}``
placeholders in ``prompt.md``.
"""
from ...agent_cli import list_base_models
context: Dict[str, Any] = {
"model_path": model_path,
"hf_url": "",
"repo": "",
"readme_content": "",
"current_metadata": {},
"base_models": [],
}
context["current_metadata"] = {
"file_name": metadata.get("file_name", ""),
"base_model": metadata.get("base_model", ""),
"tags": metadata.get("tags", []),
"modelDescription": metadata.get("modelDescription", ""),
"trainedWords": metadata.get("trainedWords", []),
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
"size": metadata.get("size", 0),
}
hf_url = metadata.get("hf_url", "")
context["hf_url"] = hf_url
repo = self._extract_repo_from_url(hf_url) if hf_url else ""
context["repo"] = repo or ""
if repo:
readme = await self._fetch_readme(repo)
context["readme_content"] = readme[:8000] if readme else "(README not available)"
try:
context["base_models"] = await list_base_models()
except Exception as exc:
logger.debug("Failed to list base models: %s", exc)
return context
@staticmethod
def _extract_repo_from_url(hf_url: str) -> Optional[str]:
"""Extract ``user/repo`` from a HuggingFace URL."""
if not hf_url:
return None
m = re.match(r"https?://huggingface\.co/([^/]+/[^/]+)", hf_url)
return m.group(1) if m else None
@staticmethod
async def _fetch_readme(repo: str) -> str:
"""Fetch README.md from HuggingFace (tries ``main``, then ``master``)."""
async with aiohttp.ClientSession(
headers={"User-Agent": "ComfyUI-LoRA-Manager/1.0"},
timeout=aiohttp.ClientTimeout(total=30),
) as session:
for branch in ("main", "master"):
url = f"https://huggingface.co/{repo}/raw/{branch}/README.md"
try:
async with session.get(url) as resp:
if resp.status == 200:
return await resp.text()
except Exception as exc:
logger.debug("Failed to fetch README from %s: %s", url, exc)
return ""
async def _emit_progress(
self,
callback: Optional[AgentProgressReporter],
skill_name: str,
*,
status: str,
**extra: Any,
) -> None:
"""Send a progress update via WebSocket (if callback is set)."""
payload: Dict[str, Any] = {"type": "agent_progress", "skill": skill_name, "status": status}
payload.update(extra)
if callback is not None:
await callback.on_progress(payload)
+168
View File
@@ -0,0 +1,168 @@
"""Post-processing engine for agent skill 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 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`.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
class PostProcessor:
"""Deterministic post-processor for agent skill outputs.
Usage (called by :class:`~py.services.agent.agent_service.AgentService`)::
processor = PostProcessor()
result = await processor.process(
skill_name="enrich_hf_metadata",
model_path="/path/to/model.safetensors",
llm_output={...},
metadata={...}, # from agent_cli.read_metadata()
)
"""
async def process(
self,
*,
skill_name: str,
model_path: str,
llm_output: Dict[str, Any],
metadata: Dict[str, Any],
) -> Dict[str, Any]:
"""Route *llm_output* to the correct skill post-processor.
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,
)
return {
"success": False,
"updated_fields": [],
"errors": [f"No post-processor registered for skill: {skill_name}"],
}
# ------------------------------------------------------------------
# enrich_hf_metadata
# ------------------------------------------------------------------
async def _process_enrich_hf_metadata(
self,
model_path: str,
llm_output: Dict[str, Any],
metadata: Dict[str, Any],
) -> Dict[str, Any]:
from ...agent_cli import (
apply_metadata_updates,
download_preview,
refresh_cache,
)
updated_fields: List[str] = []
preview_downloaded = False
# -- Determine whether this is an HF-sourced model -----------------
is_hf_model = not metadata.get("from_civitai", True)
# -- Collect updates -----------------------------------------------
updates: Dict[str, Any] = {}
# base_model
new_base = (llm_output.get("base_model") or "").strip()
current_base = metadata.get("base_model", "") or ""
if new_base and self._should_overwrite(current_base, is_hf_model):
updates["base_model"] = new_base
# trainedWords / trigger words
new_triggers = llm_output.get("trigger_words", [])
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
# 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
# tags — merge with existing, deduplicate (case-insensitive)
new_tags = llm_output.get("tags", [])
if isinstance(new_tags, list) and new_tags:
existing_tags = metadata.get("tags") or []
merged = self._merge_tags(existing_tags, new_tags)
if len(merged) > len(existing_tags) or is_hf_model:
updates["tags"] = merged
# metadata_source & llm_enriched_at (always set)
updates["metadata_source"] = "agent:enrich_hf_metadata"
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
# -- Persist updates ------------------------------------------------
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)
return {
"success": True,
"updated_fields": updated_fields,
"preview_downloaded": preview_downloaded,
"errors": [],
}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _should_overwrite(current_value: str, is_hf_model: bool) -> bool:
"""Return ``True`` when a scalar field should be overwritten."""
return is_hf_model or not current_value or current_value.lower() in (
"", "unknown",
)
@staticmethod
def _should_overwrite_list(current_list: List[str], is_hf_model: bool) -> bool:
"""Return ``True`` when a list field should be overwritten."""
return is_hf_model or not current_list
@staticmethod
def _merge_tags(existing: List[str], new: List[str]) -> List[str]:
"""Merge *new* tags into *existing*, all lowercased.
This matches the behaviour of :class:`TagUpdateService` which
normalises every tag to lowercase for case-insensitive dedup.
"""
merged: List[str] = []
seen: set = set()
for tag in list(existing) + list(new):
t = tag.strip().lower()
if t and t not in seen:
merged.append(t)
seen.add(t)
return merged
+45
View File
@@ -0,0 +1,45 @@
"""Skill definition data structures.
Each skill is described by a :class:`SkillDefinition` that declares its
input/output schemas, whether it needs an LLM call, and what permissions
its post-processor has.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
@dataclass(frozen=True)
class SkillPermissions:
"""Declarative permission scope for a skill's post-processor.
These are auditable constraints the :class:`AgentService` checks them
before invoking the handler. They are defense-in-depth, not a sandbox.
"""
write_metadata: bool = True
write_previews: bool = True
network_domains: Tuple[str, ...] = ()
@dataclass(frozen=True)
class SkillDefinition:
"""Immutable description of an agent skill."""
name: str
title: str
description: str
llm_required: bool
input_schema: Dict[str, Any] = field(default_factory=dict)
output_schema: Dict[str, Any] = field(default_factory=dict)
model_type_filter: Optional[List[str]] = None
permissions: SkillPermissions = field(default_factory=SkillPermissions)
def applies_to_model_type(self, model_type: str) -> bool:
"""Return ``True`` if this skill can run on the given model type."""
if self.model_type_filter is None:
return True
return model_type in self.model_type_filter
+184
View File
@@ -0,0 +1,184 @@
"""Discovery and loading of agent skills.
Skills live in ``py/services/agent/skills/<name>/`` directories. Each
directory must contain a ``SKILL.md`` file with YAML frontmatter::
---
name: my_skill
title: "My Skill"
description: "What this skill does"
llm_required: true
---
Prompt template with ``{{variable}}`` placeholders.
The registry scans the skills directory on first access and caches results.
"""
from __future__ import annotations
import asyncio
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
from .skill_definition import SkillDefinition, SkillPermissions
logger = logging.getLogger(__name__)
# Directory where built-in skills are stored
_SKILLS_DIR = Path(__file__).parent / "skills"
# ---------------------------------------------------------------------------
# Frontmatter parser
# ---------------------------------------------------------------------------
_FRONTMATTER_RE = re.compile(
r"^---\s*\n(.*?\n)---\s*\n?(.*)", re.DOTALL
)
def _parse_skill_file(path: Path) -> tuple[dict, str]:
"""Read a ``SKILL.md`` file and return (frontmatter_dict, body_text).
Raises ``ValueError`` if the file lacks valid YAML frontmatter.
"""
text = path.read_text(encoding="utf-8")
m = _FRONTMATTER_RE.match(text)
if not m:
raise ValueError(f"Missing or invalid YAML frontmatter in {path}")
frontmatter = yaml.safe_load(m.group(1))
if not isinstance(frontmatter, dict):
raise ValueError(f"Frontmatter in {path} is not a mapping")
body = m.group(2).strip()
return frontmatter, body
class SkillRegistry:
"""Discover and load agent skills from the filesystem."""
_instance: Optional["SkillRegistry"] = None
_lock: asyncio.Lock = asyncio.Lock()
def __init__(self, skills_dir: Path = _SKILLS_DIR) -> None:
self._skills_dir = skills_dir
self._skills: Dict[str, SkillDefinition] = {}
self._loaded: bool = False
# ------------------------------------------------------------------
# Singleton access
# ------------------------------------------------------------------
@classmethod
async def get_instance(cls) -> "SkillRegistry":
"""Return the lazily-initialised global ``SkillRegistry``."""
if cls._instance is None:
async with cls._lock:
if cls._instance is None:
registry = cls()
registry._discover()
cls._instance = registry
return cls._instance
@classmethod
def reset_instance(cls) -> None:
"""Reset the cached singleton — primarily for tests."""
cls._instance = None
# ------------------------------------------------------------------
# Discovery
# ------------------------------------------------------------------
def _discover(self) -> None:
"""Scan the skills directory and load all valid skill definitions."""
self._skills.clear()
if not self._skills_dir.is_dir():
logger.warning("Skills directory does not exist: %s", self._skills_dir)
self._loaded = True
return
for entry in sorted(self._skills_dir.iterdir()):
if not entry.is_dir():
continue
skill_md = entry / "SKILL.md"
if not skill_md.exists():
continue
try:
definition = self._load_skill_definition(skill_md)
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)
self._loaded = True
logger.info("Discovered %d agent skills", len(self._skills))
def _load_skill_definition(self, path: Path) -> Optional[SkillDefinition]:
"""Parse a ``SKILL.md`` 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)
return None
if "name" not in data:
logger.warning("SKILL.md missing required 'name' field: %s", path)
return None
perm_data = data.get("permissions", {})
permissions = SkillPermissions(
write_metadata=perm_data.get("write_metadata", True),
write_previews=perm_data.get("write_previews", True),
network_domains=tuple(perm_data.get("network_domains", [])),
)
return SkillDefinition(
name=data["name"],
title=data.get("title", data["name"]),
description=data.get("description", ""),
llm_required=data.get("llm_required", False),
input_schema=data.get("input_schema", {}),
output_schema=data.get("output_schema", {}),
model_type_filter=data.get("model_type_filter"),
permissions=permissions,
)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def list_skills(self) -> List[SkillDefinition]:
"""Return all discovered skill definitions."""
if not self._loaded:
self._discover()
return list(self._skills.values())
def get_skill(self, name: str) -> Optional[SkillDefinition]:
"""Return the skill definition for ``name``, or ``None`` if not found."""
if not self._loaded:
self._discover()
return self._skills.get(name)
def load_prompt(self, name: str) -> str:
"""Load and return the prompt template body from a skill's ``SKILL.md``."""
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}")
try:
_frontmatter, body = _parse_skill_file(skill_path)
return body
except (ValueError, yaml.YAMLError) as exc:
raise ValueError(f"Failed to parse prompt from {skill_path}: {exc}") from exc
@@ -0,0 +1,89 @@
---
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
+72 -2
View File
@@ -84,6 +84,7 @@ class Aria2Downloader:
self._transfers: Dict[str, Aria2Transfer] = {}
self._poll_interval = 0.5
self._state_store = Aria2TransferStateStore()
self._stderr_reader_task: Optional[asyncio.Task] = None
@property
def is_running(self) -> bool:
@@ -115,7 +116,7 @@ class Aria2Downloader:
try:
while True:
status = await self.get_status(download_id)
status = await self._get_status_with_retry(download_id)
if status is None:
return False, "aria2 download not found"
@@ -136,6 +137,35 @@ class Aria2Downloader:
finally:
self._transfers.pop(download_id, None)
async def _get_status_with_retry(
self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0
) -> Optional[Dict[str, Any]]:
"""Call get_status with retry for transient RPC failures.
Only retries on :exc:`Aria2Error` (RPC-level failure). Returns
``None`` immediately when the download_id is not tracked (a missing
transfer is not a transient condition, so retrying is pointless).
A single failed RPC call should not immediately fail the download,
because aria2 may be temporarily busy (e.g. finalizing multiple
concurrent downloads) and a retry will often succeed.
"""
last_exc: Optional[Exception] = None
for attempt in range(max_retries):
try:
return await self.get_status(download_id)
except Aria2Error as exc:
last_exc = exc
if attempt < max_retries - 1:
logger.warning(
"aria2 get_status transient failure (attempt %d/%d) for %s: %s",
attempt + 1, max_retries, download_id, exc,
)
await asyncio.sleep(retry_delay)
raise Aria2Error(
f"Failed to query aria2 download status after {max_retries} attempts: {last_exc}"
) from last_exc
async def _schedule_download(
self,
url: str,
@@ -312,6 +342,16 @@ class Aria2Downloader:
async def close(self) -> None:
"""Shut down the RPC process and session."""
# Cancel the background stderr reader first so it stops reading
# from the pipe before the subprocess is terminated.
if self._stderr_reader_task is not None:
self._stderr_reader_task.cancel()
try:
await asyncio.wait_for(self._stderr_reader_task, timeout=2.0)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
self._stderr_reader_task = None
if self._rpc_session is not None:
await self._rpc_session.close()
self._rpc_session = None
@@ -331,6 +371,23 @@ class Aria2Downloader:
process.kill()
await process.wait()
async def _drain_stderr(self) -> None:
"""Continuously drain aria2's stderr pipe so it never blocks.
When the 64 KB pipe buffer fills up, aria2's ``write()`` to stderr
blocks, which freezes the entire ``aria2c`` process including its
RPC handler. This background task reads lines from stderr as they
arrive and forwards them to Python's logger.
"""
try:
assert self._process is not None and self._process.stderr is not None
async for line in self._process.stderr:
text = line.decode("utf-8", errors="replace").rstrip()
if text:
logger.debug("aria2 stderr: %s", text)
except Exception:
pass
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
try:
result = callback(snapshot, snapshot)
@@ -465,6 +522,17 @@ class Aria2Downloader:
await self._wait_until_ready()
# Drain aria2's stderr in a background task so the pipe buffer
# never fills up. If the pipe blocks, aria2 itself freezes and
# cannot respond to RPC — this was the root cause of the
# "Failed to query aria2 download status" timeout bug.
# Must start AFTER _wait_until_ready to avoid a race where the
# drain task consumes aria2's early-exit error message before
# _wait_until_ready can read it.
self._stderr_reader_task = asyncio.create_task(
self._drain_stderr()
)
def _resolve_executable(self) -> str:
settings = get_settings_manager()
configured_path = (settings.get("aria2c_path") or "").strip()
@@ -584,7 +652,9 @@ class Aria2Downloader:
if self._rpc_session is None or self._rpc_session.closed:
async with self._rpc_session_lock:
if self._rpc_session is None or self._rpc_session.closed:
timeout = aiohttp.ClientTimeout(total=30)
timeout = aiohttp.ClientTimeout(
total=None, sock_connect=10, sock_read=60
)
self._rpc_session = aiohttp.ClientSession(timeout=timeout)
return self._rpc_session
+103 -4
View File
@@ -104,6 +104,100 @@ class BaseModelService(ABC):
fetch_duration = time.perf_counter() - t0
initial_count = len(sorted_data)
# Optionally filter by civitai model ID (shows all local versions of a specific model)
civitai_model_id = kwargs.get("civitai_model_id")
if civitai_model_id is not None:
sorted_data = [
item for item in sorted_data
if self._extract_model_id(item) == civitai_model_id
]
# VLM mode: always sort by version ID descending (newest version first),
# regardless of the current sort_by preference.
sorted_data.sort(
key=lambda x: self._extract_version_id(x) or 0,
reverse=True,
)
# Optionally group by civitai modelId, showing only the latest version per model
dedup_lost = 0
if kwargs.get("group_by_model") and civitai_model_id is None:
# Determine whether to further sub-group by base model
# When version_grouping is "same_base", versions with different
# base models are effectively different groups — the dedup key
# needs to include base_model so the version count and VLM flow
# stay consistent (card shows correct count for its base model).
ufs = self.settings.get("version_grouping", "same_base")
group_by_base = ufs == "same_base"
dedup_map = {} # (modelId [,base_model]) -> (item, version_id)
version_counter = {} # same-key -> count
standalone = []
for item in sorted_data:
mid = self._extract_model_id(item)
if mid is None:
standalone.append(item)
continue
key = (mid, item.get("base_model") or "") if group_by_base else mid
# Count all versions per key
version_counter[key] = version_counter.get(key, 0) + 1
vid = self._extract_version_id(item) or 0
if key not in dedup_map or vid > dedup_map[key][1]:
dedup_map[key] = (item, vid)
# Attach version_count to each surviving grouped item (shallow copy
# to avoid mutating cached dicts — the cache is shared across requests)
for key, (item, vid) in dedup_map.items():
item = dict(item)
item["version_count"] = version_counter[key]
dedup_map[key] = (item, vid)
dedup_lost = len(sorted_data) - (len(dedup_map) + len(standalone))
sorted_data = [entry[0] for entry in dedup_map.values()] + standalone
# Re-sort by version_count (grouped: after dedup; non-grouped: group internally, sort, expand)
if sort_params.key == "versions_count" and civitai_model_id is None:
reverse = sort_params.order == "desc"
if kwargs.get("group_by_model"):
# Grouped mode: items are already dedup'd with version_count attached
sorted_data.sort(
key=lambda x: (
x.get("version_count", 0),
(x.get("model_name") or x.get("file_name") or "").lower(),
x.get("file_path", "").lower(),
),
reverse=reverse,
)
else:
# Non-grouped mode: group internally, sort groups by count, expand
# Respect the version_grouping setting (same logic as grouped dedup)
ufs = self.settings.get("version_grouping", "same_base")
group_by_base = ufs == "same_base"
model_groups: Dict[Any, List[Dict]] = {}
ungrouped_standalone: List[Dict] = []
for item in sorted_data:
mid = self._extract_model_id(item)
if mid is None:
ungrouped_standalone.append(item)
continue
key = (mid, item.get("base_model") or "") if group_by_base else mid
model_groups.setdefault(key, []).append(item)
# Sort versions within each group by version id descending
for items in model_groups.values():
items.sort(
key=lambda x: self._extract_version_id(x) or 0,
reverse=True,
)
# Sort groups by version count
sorted_groups = sorted(
model_groups.values(),
key=lambda items: len(items),
reverse=reverse,
)
# Flatten: grouped items first, standalone items last
sorted_data = []
for items in sorted_groups:
sorted_data.extend(items)
sorted_data.extend(ungrouped_standalone)
t1 = time.perf_counter()
if hash_filters:
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
@@ -172,7 +266,7 @@ class BaseModelService(ABC):
overall_duration = time.perf_counter() - overall_start
logger.debug(
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
"Counts: initial=%d, post_filter=%d, final=%d",
"Counts: initial=%d, dedup=%d, post_filter=%d, final=%d",
self.__class__.__name__,
overall_duration,
fetch_duration,
@@ -181,6 +275,7 @@ class BaseModelService(ABC):
pagination_duration,
annotate_duration,
initial_count,
dedup_lost,
post_filter_count,
final_count,
)
@@ -495,7 +590,7 @@ class BaseModelService(ABC):
if not ordered_ids:
return annotated
strategy_value = self.settings.get("update_flag_strategy")
strategy_value = self.settings.get("version_grouping")
if isinstance(strategy_value, str) and strategy_value.strip():
strategy = strategy_value.strip().lower()
else:
@@ -696,8 +791,12 @@ class BaseModelService(ABC):
}
@abstractmethod
async def format_response(self, model_data: Dict) -> Dict:
"""Format model data for API response - must be implemented by subclasses"""
async def format_response(self, model_data: Dict) -> Optional[Dict]:
"""Format model data for API response - must be implemented by subclasses.
Subclasses should return None for corrupted entries so the handler
layer can filter them out. See issue #730.
"""
pass
# Common service methods that delegate to scanner
+4
View File
@@ -523,6 +523,10 @@ class BatchImportService:
if payload.get("checkpoint"):
metadata["checkpoint"] = payload["checkpoint"]
nsfw = payload.get("preview_nsfw_level")
if isinstance(nsfw, int) and nsfw > 0:
metadata["preview_nsfw_level"] = nsfw
image_bytes = None
image_base64 = payload.get("image_base64")
+27 -8
View File
@@ -1,6 +1,6 @@
import os
import logging
from typing import Dict
from typing import Dict, Optional
from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags
@@ -21,20 +21,37 @@ class CheckpointService(BaseModelService):
"""
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
async def format_response(self, checkpoint_data: Dict) -> Dict:
"""Format Checkpoint data for API response"""
async def format_response(self, checkpoint_data: Dict) -> Optional[Dict]:
"""Format Checkpoint data for API response.
Returns None when the entry is missing critical fields (corrupted cache
row), so the handler layer can filter it out. See issue #730.
"""
# Guard against corrupted cache entries missing critical fields
file_path = checkpoint_data.get("file_path")
if not file_path or not isinstance(file_path, str):
logger.warning(
"Skipping corrupted checkpoint entry (missing file_path): %s",
checkpoint_data.get("file_name", "<unknown>"),
)
return None
# Get sub_type from cache entry (new canonical field)
sub_type = checkpoint_data.get("sub_type", "checkpoint")
file_name = checkpoint_data.get("file_name") or ""
model_name = checkpoint_data.get("model_name") or file_name
folder = checkpoint_data.get("folder") or ""
return {
"model_name": checkpoint_data["model_name"],
"file_name": checkpoint_data["file_name"],
"model_name": model_name,
"file_name": file_name,
"preview_url": config.get_preview_static_url(checkpoint_data.get("preview_url", "")),
"preview_nsfw_level": checkpoint_data.get("preview_nsfw_level", 0),
"base_model": checkpoint_data.get("base_model", ""),
"folder": checkpoint_data["folder"],
"folder": folder,
"sha256": checkpoint_data.get("sha256", ""),
"file_path": checkpoint_data["file_path"].replace(os.sep, "/"),
"file_path": file_path.replace(os.sep, "/"),
"file_size": checkpoint_data.get("size", 0),
"modified": checkpoint_data.get("modified", ""),
"tags": checkpoint_data.get("tags", []),
@@ -48,6 +65,8 @@ class CheckpointService(BaseModelService):
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
"version_count": checkpoint_data.get("version_count"),
"hf_url": checkpoint_data.get("hf_url", ""),
}
def find_duplicate_hashes(self) -> Dict:
+2 -2
View File
@@ -327,7 +327,7 @@ class CivArchiveClient:
if resolved:
return resolved, None
logger.error("Error fetching version of CivArchive model by hash %s", model_hash[:10])
logger.debug("Error fetching version of CivArchive model by hash %s", model_hash[:10])
return None, "No version data found"
except RateLimitError:
@@ -417,7 +417,7 @@ class CivArchiveClient:
if version_id is not None:
raw_id = version_data.get("id")
if raw_id != version_id:
if raw_id is not None and str(raw_id) != str(version_id):
logger.warning(
"Requested version %s doesn't match default version %s for model %s",
version_id,
@@ -196,6 +196,7 @@ class CivitaiBaseModelService:
"ernie": "ERNI",
"ernie turbo": "ETRB",
"nucleus": "NUCL",
"krea 2": "KR2",
"svd": "SVD",
"ltxv": "LTXV",
"ltxv2": "LTV2",
@@ -424,6 +425,7 @@ class CivitaiBaseModelService:
"Ernie",
"Ernie Turbo",
"Nucleus",
"Krea 2",
],
}
+1 -1
View File
@@ -56,7 +56,7 @@ class CivitaiClient:
self._MAX_CACHE_ENTRIES = 500
def _build_image_info_url(self, image_id: str) -> str:
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
return f"{self.base_url}/images?imageId={image_id}&nsfw=X&withMeta=true"
async def _make_request(
self,
+33 -5
View File
@@ -1288,10 +1288,24 @@ class DownloadManager:
"download_id": download_id,
}
# Check if this checkpoint should be treated as a diffusion model based on baseModel
# Check if this checkpoint should be treated as a diffusion model
# Priority: (1) any file has type "UNet" or "Diffusion Model",
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
is_diffusion_model = False
if model_type == "checkpoint":
if base_model_value in DIFFUSION_MODEL_BASE_MODELS:
# Check file types first (more direct signal from CivitAI)
version_files = version_info.get("files", [])
for f in version_files:
f_type = f.get("type", "")
if f_type in ("UNet", "Diffusion Model"):
is_diffusion_model = True
logger.info(
f"File type '{f_type}' detected, routing checkpoint to unet folder"
)
break
# Fallback to baseModel name check
if not is_diffusion_model and base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True
logger.info(
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
@@ -1420,7 +1434,7 @@ class DownloadManager:
f
for f in files
if f.get("primary")
and f.get("type") in ("Model", "Negative", "Diffusion Model")
and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
),
None,
)
@@ -1451,7 +1465,7 @@ class DownloadManager:
(
f
for f in files
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model")
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
),
None,
)
@@ -2029,7 +2043,21 @@ class DownloadManager:
break
last_error = result
if os.path.exists(save_path):
# For aria2: if the .aria2 control file is missing, aria2 considers
# the download complete. A transient RPC failure may have made us
# think the download failed even though the file is fully on disk.
# Keep the file so a retry can find it already complete.
if (
transfer_backend == "aria2"
and os.path.exists(save_path)
and not os.path.exists(f"{save_path}.aria2")
):
logger.warning(
"aria2 download reported failure but .aria2 file is absent "
"for %s — the file is likely complete. Preserving it for retry.",
save_path,
)
elif os.path.exists(save_path):
try:
os.remove(save_path)
except Exception as e:
+27 -8
View File
@@ -1,6 +1,6 @@
import os
import logging
from typing import Dict
from typing import Dict, Optional
from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags
@@ -21,20 +21,37 @@ class EmbeddingService(BaseModelService):
"""
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
async def format_response(self, embedding_data: Dict) -> Dict:
"""Format Embedding data for API response"""
async def format_response(self, embedding_data: Dict) -> Optional[Dict]:
"""Format Embedding data for API response.
Returns None when the entry is missing critical fields (corrupted cache
row), so the handler layer can filter it out. See issue #730.
"""
# Guard against corrupted cache entries missing critical fields
file_path = embedding_data.get("file_path")
if not file_path or not isinstance(file_path, str):
logger.warning(
"Skipping corrupted embedding entry (missing file_path): %s",
embedding_data.get("file_name", "<unknown>"),
)
return None
# Get sub_type from cache entry (new canonical field)
sub_type = embedding_data.get("sub_type", "embedding")
file_name = embedding_data.get("file_name") or ""
model_name = embedding_data.get("model_name") or file_name
folder = embedding_data.get("folder") or ""
return {
"model_name": embedding_data["model_name"],
"file_name": embedding_data["file_name"],
"model_name": model_name,
"file_name": file_name,
"preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")),
"preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0),
"base_model": embedding_data.get("base_model", ""),
"folder": embedding_data["folder"],
"folder": folder,
"sha256": embedding_data.get("sha256", ""),
"file_path": embedding_data["file_path"].replace(os.sep, "/"),
"file_path": file_path.replace(os.sep, "/"),
"file_size": embedding_data.get("size", 0),
"modified": embedding_data.get("modified", ""),
"tags": embedding_data.get("tags", []),
@@ -48,6 +65,8 @@ class EmbeddingService(BaseModelService):
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
"version_count": embedding_data.get("version_count"),
"hf_url": embedding_data.get("hf_url", ""),
}
def find_duplicate_hashes(self) -> Dict:
+18
View File
@@ -25,3 +25,21 @@ class ResourceNotFoundError(RuntimeError):
pass
class LLMNotConfiguredError(RuntimeError):
"""Raised when an LLM-dependent operation is attempted but no provider is configured."""
pass
class LLMRateLimitError(RateLimitError):
"""Raised when the LLM provider rejects a request due to rate limiting."""
pass
class LLMResponseError(RuntimeError):
"""Raised when the LLM returns an unparseable or schema-invalid response."""
pass
+321
View File
@@ -0,0 +1,321 @@
"""Centralized LLM API client with BYOK (bring-your-own-key) provider support.
Reads provider configuration from :class:`SettingsManager` and makes
OpenAI-compatible ``/chat/completions`` calls. Supports any provider that
implements the OpenAI Chat Completions API surface area (OpenAI, Ollama,
vLLM, LM Studio, etc.).
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Dict, List, Optional
import aiohttp
from .errors import LLMNotConfiguredError, LLMRateLimitError, LLMResponseError
logger = logging.getLogger(__name__)
# Default API base URLs per provider
_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
}
# Request timeout for LLM calls (seconds)
_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.
"""
_instance: Optional["LLMService"] = None
_lock: asyncio.Lock = asyncio.Lock()
def __init__(self, settings_service) -> None:
self._settings = settings_service
# ------------------------------------------------------------------
# Singleton access
# ------------------------------------------------------------------
@classmethod
async def get_instance(cls) -> "LLMService":
"""Return the lazily-initialised global ``LLMService`` instance."""
if cls._instance is None:
async with cls._lock:
if cls._instance is None:
from .settings_manager import get_settings_manager
cls._instance = cls(get_settings_manager())
return cls._instance
@classmethod
def reset_instance(cls) -> None:
"""Reset the cached singleton — primarily for tests."""
cls._instance = None
# ------------------------------------------------------------------
# Configuration helpers
# ------------------------------------------------------------------
def _get_config(self) -> Dict[str, Any]:
"""Read the current LLM configuration from settings."""
return {
"provider": self._settings.get("llm_provider", "openai"),
"api_key": self._settings.get("llm_api_key", ""),
"api_base": self._settings.get("llm_api_base", ""),
"model": self._settings.get("llm_model", ""),
}
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.
"""
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
def _resolve_api_base(self, provider: str, api_base: str) -> str:
"""Resolve the API base URL for the given provider."""
if api_base:
return api_base.rstrip("/")
return _PROVIDER_DEFAULTS.get(provider, "").rstrip("/")
def _build_headers(self, api_key: str) -> Dict[str, str]:
"""Build HTTP headers for the LLM API request."""
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
def _ensure_configured(self) -> Dict[str, Any]:
"""Validate configuration and return it, or raise.
A provider is considered configured when ``llm_model`` is set and
(for non-Ollama) an API key is configured.
"""
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):
parts = []
if not has_model:
parts.append("No LLM model specified")
if not has_key and cfg["provider"] != "ollama":
parts.append("No LLM API key configured")
detail = "; ".join(parts) if parts else "LLM provider is not configured"
raise LLMNotConfiguredError(
f"{detail}. Configure it in Settings → AI Provider."
)
return cfg
# ------------------------------------------------------------------
# Core API call
# ------------------------------------------------------------------
async def chat_completion(
self,
*,
messages: List[Dict[str, str]],
model: Optional[str] = None,
temperature: float = 0.3,
response_format: Optional[Dict[str, Any]] = None,
max_tokens: Optional[int] = None,
retry_on_rate_limit: bool = True,
) -> Dict[str, Any]:
"""Call the configured LLM provider's ``/chat/completions`` endpoint.
Args:
messages: OpenAI-format message list
model: Override the configured model name
temperature: Sampling temperature
response_format: Optional ``{"type": "json_object"}`` for structured output
max_tokens: Optional max output tokens
retry_on_rate_limit: Retry once after a 429 with backoff
Returns:
Dict with ``content`` (str), ``usage`` (dict), ``model`` (str)
Raises:
LLMNotConfiguredError: Provider not enabled / missing config
LLMRateLimitError: Rate limited and retry exhausted
LLMResponseError: Non-200 response or parse failure
"""
cfg = self._ensure_configured()
api_base = self._resolve_api_base(cfg["provider"], cfg["api_base"])
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
headers = self._build_headers(cfg["api_key"])
attempt = 0
max_attempts = 2 if retry_on_rate_limit else 1
while attempt < max_attempts:
attempt += 1
try:
async with aiohttp.ClientSession(timeout=_LLM_TIMEOUT) as session:
async with session.post(
url, json=payload, headers=headers
) as resp:
if resp.status == 429:
if attempt < max_attempts:
retry_after = float(
resp.headers.get("Retry-After", "5")
)
logger.warning(
"LLM rate limited, retrying after %.1fs",
retry_after,
)
await asyncio.sleep(retry_after)
continue
raise LLMRateLimitError(
f"LLM provider rate limited (HTTP 429)",
provider=cfg["provider"],
)
if resp.status != 200:
body = await resp.text()
raise LLMResponseError(
f"LLM API returned HTTP {resp.status}: "
f"{body[:500]}"
)
data = await resp.json()
except aiohttp.ClientError as exc:
raise LLMResponseError(f"Network error calling LLM API: {exc}") from exc
# Parse response
try:
content = data["choices"][0]["message"]["content"]
usage = data.get("usage", {})
return {
"content": content,
"usage": usage,
"model": data.get("model", model_name),
}
except (KeyError, IndexError) as exc:
raise LLMResponseError(
f"Unexpected LLM response structure: {json.dumps(data)[:500]}"
) from exc
# Should not reach here, but satisfy type checker
raise LLMRateLimitError("Rate limit retry exhausted", provider=cfg["provider"])
# ------------------------------------------------------------------
# Structured output convenience
# ------------------------------------------------------------------
async def chat_completion_json(
self,
*,
system_prompt: str,
user_prompt: str,
model: Optional[str] = None,
temperature: float = 0.3,
max_tokens: Optional[int] = None,
) -> Dict[str, Any]:
"""Call the LLM 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.
Args:
system_prompt: System-level instructions
user_prompt: User-level query
model: Override the configured model name
temperature: Sampling temperature
max_tokens: Optional max output tokens
Returns:
Parsed JSON dict from the LLM response
Raises:
LLMNotConfiguredError: Provider not configured
LLMRateLimitError: Rate limited
LLMResponseError: JSON parse failure after retry
"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
# First attempt with JSON mode
result = await self.chat_completion(
messages=messages,
model=model,
temperature=temperature,
response_format={"type": "json_object"},
max_tokens=max_tokens,
)
try:
return json.loads(result["content"])
except (json.JSONDecodeError, TypeError) as exc:
logger.warning(
"LLM JSON parse failed on first attempt: %s. Retrying.", exc
)
# 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."
),
},
]
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,
)
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
+26 -6
View File
@@ -24,23 +24,41 @@ class LoraService(BaseModelService):
"""
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
async def format_response(self, lora_data: Dict) -> Dict:
"""Format LoRA data for API response"""
async def format_response(self, lora_data: Dict) -> Optional[Dict]:
"""Format LoRA data for API response.
Returns None when the entry is missing critical fields (corrupted cache
row), so the handler layer can filter it out instead of crashing the
whole listing request. See issue #730.
"""
# Guard against corrupted cache entries missing critical fields
file_path = lora_data.get("file_path")
if not file_path or not isinstance(file_path, str):
logger.warning(
"Skipping corrupted LoRA entry (missing file_path): %s",
lora_data.get("file_name", "<unknown>"),
)
return None
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
# Normalize to lowercase for consistent API responses
sub_type = resolve_sub_type(lora_data).lower()
file_name = lora_data.get("file_name") or ""
model_name = lora_data.get("model_name") or file_name
folder = lora_data.get("folder") or ""
return {
"model_name": lora_data["model_name"],
"file_name": lora_data["file_name"],
"model_name": model_name,
"file_name": file_name,
"preview_url": config.get_preview_static_url(
lora_data.get("preview_url", "")
),
"preview_nsfw_level": lora_data.get("preview_nsfw_level", 0),
"base_model": lora_data.get("base_model", ""),
"folder": lora_data["folder"],
"folder": folder,
"sha256": lora_data.get("sha256", ""),
"file_path": lora_data["file_path"].replace(os.sep, "/"),
"file_path": file_path.replace(os.sep, "/"),
"file_size": lora_data.get("size", 0),
"modified": lora_data.get("modified", ""),
"tags": lora_data.get("tags", []),
@@ -59,6 +77,8 @@ class LoraService(BaseModelService):
lora_data.get("civitai", {}), minimal=True
),
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
"version_count": lora_data.get("version_count"),
"hf_url": lora_data.get("hf_url", ""),
}
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
+13
View File
@@ -18,6 +18,8 @@ SUPPORTED_SORT_MODES = [
('size', 'desc'),
('usage', 'asc'),
('usage', 'desc'),
('versions_count', 'asc'),
('versions_count', 'desc'),
]
# Is this in use?
@@ -263,6 +265,17 @@ class ModelCache:
),
reverse=reverse
)
elif sort_key == 'versions_count':
# Pre-dedup sort: fall back to name sort.
# Actual re-sort by version_count happens in get_paginated_data after dedup.
result = natsorted(
data,
key=lambda x: (
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse
)
else:
# Fallback: no sort
result = list(data)
+11 -1
View File
@@ -248,6 +248,7 @@ class ModelScanner:
'civitai': civitai_slim,
'civitai_deleted': bool(get_value('civitai_deleted', False)),
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
'hf_url': get_value('hf_url', '') or '',
}
license_source: Dict[str, Any] = {}
@@ -476,11 +477,20 @@ class ModelScanner:
for tag in adjusted_item.get('tags') or []:
tags_count[tag] = tags_count.get(tag, 0) + 1
# Validate cache entries and check health
# Validate cache entries and check health.
# Always use the validated/repaired entries — even when there are no
# invalid entries, auto_repair may have filled in missing optional
# fields (model_name, file_name, folder) with safe defaults on a copied
# working_entry. Without this unconditional replacement the repaired
# copies are discarded and None values propagate to format_response.
# See issue #730.
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
adjusted_raw_data, auto_repair=True
)
# Always use the validated entries (repaired copies)
adjusted_raw_data = valid_entries
if invalid_entries:
monitor = CacheHealthMonitor()
report = monitor.check_health(adjusted_raw_data, auto_repair=True)
+49 -2
View File
@@ -724,6 +724,16 @@ class ModelUpdateService:
"Refreshing update metadata for %d %s models", total_models, model_type
)
# When filtering by folder, also collect the cross-folder version set
# so that versions already present in other folders are not reported
# as available updates. See issue #997.
all_local_versions: Optional[Dict[int, List[int]]] = None
if folder_path is not None:
all_local_versions = await self._collect_local_versions(
scanner,
target_model_ids=target_filter,
)
results: Dict[int, ModelUpdateRecord] = {}
prefetched: Dict[int, Mapping] = {}
@@ -762,6 +772,12 @@ class ModelUpdateService:
for index, (model_id, version_ids) in enumerate(
local_versions.items(), start=1
):
# Use cross-folder version IDs for is_in_library if available
all_vids: Sequence[int] = (
all_local_versions.get(model_id, [])
if all_local_versions is not None
else version_ids
)
record = await self._refresh_single_model(
model_type,
model_id,
@@ -769,6 +785,7 @@ class ModelUpdateService:
metadata_provider,
force_refresh=force_refresh,
prefetched_response=prefetched.get(model_id),
all_local_version_ids=all_vids,
)
if scanner.is_cancelled():
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
@@ -964,8 +981,16 @@ class ModelUpdateService:
*,
force_refresh: bool = False,
prefetched_response: Optional[Mapping] = None,
all_local_version_ids: Optional[Sequence[int]] = None,
) -> Optional[ModelUpdateRecord]:
normalized_local = self._normalize_sequence(local_versions)
# When folder-filtering, this carries the cross-folder version set
# for is_in_library; otherwise it falls back to normalized_local.
normalized_all = (
self._normalize_sequence(all_local_version_ids)
if all_local_version_ids is not None
else normalized_local
)
now = time.time()
async with self._lock:
existing = self._get_record(model_type, model_id)
@@ -973,6 +998,7 @@ class ModelUpdateService:
record = self._merge_with_local_versions(
existing,
normalized_local,
all_local_version_ids=normalized_all,
)
self._upsert_record(record)
return record
@@ -1048,6 +1074,7 @@ class ModelUpdateService:
record = self._merge_with_local_versions(
existing,
normalized_local,
all_local_version_ids=normalized_all,
)
self._upsert_record(record)
return record
@@ -1059,6 +1086,7 @@ class ModelUpdateService:
model_type=model_type,
model_id=model_id,
last_checked_at=now,
all_local_version_ids=normalized_all,
)
record = replace(record, should_ignore_model=True)
self._upsert_record(record)
@@ -1077,6 +1105,7 @@ class ModelUpdateService:
fetched_versions,
existing,
now,
all_local_version_ids=normalized_all,
)
else:
record = self._merge_with_local_versions(
@@ -1085,6 +1114,7 @@ class ModelUpdateService:
model_type=model_type,
model_id=model_id,
last_checked_at=existing.last_checked_at if existing else None,
all_local_version_ids=normalized_all,
)
self._upsert_record(record)
return record
@@ -1322,12 +1352,20 @@ class ModelUpdateService:
existing: Optional[ModelUpdateRecord],
normalized_local: Sequence[int],
*,
all_local_version_ids: Optional[Sequence[int]] = None,
model_type: Optional[str] = None,
model_id: Optional[int] = None,
last_checked_at: Optional[float] = None,
version_info: Optional[Mapping] = None,
) -> ModelUpdateRecord:
local_set = set(normalized_local)
# When folder-filtering, also consider versions in other folders
# as in-library so they are not reported as available updates.
effective_local_set: set[int] = (
local_set | set(all_local_version_ids)
if all_local_version_ids is not None
else local_set
)
versions: List[ModelVersionRecord] = []
ignore_map: Dict[int, bool] = {}
if existing:
@@ -1339,7 +1377,7 @@ class ModelUpdateService:
versions.append(
replace(
version,
is_in_library=version.version_id in local_set,
is_in_library=version.version_id in effective_local_set,
)
)
elif model_type is None or model_id is None:
@@ -1386,8 +1424,17 @@ class ModelUpdateService:
remote_versions: Sequence[ModelVersionRecord],
existing: Optional[ModelUpdateRecord],
timestamp: float,
*,
all_local_version_ids: Optional[Sequence[int]] = None,
) -> ModelUpdateRecord:
local_set = set(local_versions)
# When folder-filtering, also consider versions in other folders
# as in-library so they are not reported as available updates.
effective_local_set: set[int] = (
local_set | set(all_local_version_ids)
if all_local_version_ids is not None
else local_set
)
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
@@ -1406,7 +1453,7 @@ class ModelUpdateService:
released_at=remote_version.released_at,
size_bytes=remote_version.size_bytes,
preview_url=remote_version.preview_url or preview_map.get(version_id),
is_in_library=version_id in local_set,
is_in_library=version_id in effective_local_set,
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
sort_index=sort_map.get(version_id, index),
early_access_ends_at=remote_version.early_access_ends_at,
+14 -9
View File
@@ -57,6 +57,7 @@ class PersistentModelCache:
"db_checked",
"last_checked_at",
"hash_status",
"hf_url",
)
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
_instances: Dict[str, "PersistentModelCache"] = {}
@@ -165,8 +166,8 @@ class PersistentModelCache:
item = {
"file_path": file_path,
"file_name": row["file_name"],
"model_name": row["model_name"],
"file_name": row["file_name"] or "",
"model_name": row["model_name"] or "",
"folder": row["folder"] or "",
"size": row["size"] or 0,
"modified": row["modified"] or 0.0,
@@ -188,6 +189,7 @@ class PersistentModelCache:
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
"license_flags": int(license_value),
"hash_status": row["hash_status"] or "completed",
"hf_url": row["hf_url"] or "",
}
raw_data.append(item)
@@ -452,6 +454,7 @@ class PersistentModelCache:
db_checked INTEGER,
last_checked_at REAL,
hash_status TEXT,
hf_url TEXT DEFAULT '',
PRIMARY KEY (model_type, file_path)
);
@@ -500,6 +503,7 @@ class PersistentModelCache:
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
"hash_status": "TEXT DEFAULT 'completed'",
"hf_url": "TEXT DEFAULT ''",
}
for column, definition in required_columns.items():
@@ -548,19 +552,19 @@ class PersistentModelCache:
return (
model_type,
item.get("file_path"),
item.get("file_name"),
item.get("model_name"),
item.get("folder"),
item.get("file_name") or "",
item.get("model_name") or "",
item.get("folder") or "",
int(item.get("size") or 0),
float(item.get("modified") or 0.0),
(item.get("sha256") or "").lower() or None,
item.get("base_model"),
item.get("preview_url"),
item.get("base_model") or "",
item.get("preview_url") or "",
int(item.get("preview_nsfw_level") or 0),
1 if item.get("from_civitai", True) else 0,
1 if item.get("favorite") else 0,
item.get("notes"),
item.get("usage_tips"),
item.get("notes") or "",
item.get("usage_tips") or "",
metadata_source,
civitai.get("id"),
civitai.get("modelId"),
@@ -575,6 +579,7 @@ class PersistentModelCache:
1 if item.get("db_checked") else 0,
float(item.get("last_checked_at") or 0.0),
item.get("hash_status", "completed"),
item.get("hf_url") or "",
)
def _insert_model_sql(self) -> str:
+96 -17
View File
@@ -146,11 +146,38 @@ class RecipeAnalysisService:
):
metadata = metadata["meta"]
# Include modelVersionIds from root level if available
# Civitai API returns modelVersionIds at root level, not in meta
# Include modelVersionIds from root level if available.
# CivitAI API returns modelVersionIds at root level, not in meta.
# When meta is null (None), create a minimal dict so downstream
# parsers can still discover LoRAs and checkpoints.
model_version_ids = image_info.get("modelVersionIds")
if model_version_ids and isinstance(metadata, dict):
metadata["modelVersionIds"] = model_version_ids
if model_version_ids:
if isinstance(metadata, dict):
metadata["modelVersionIds"] = model_version_ids
else:
metadata = {"modelVersionIds": model_version_ids}
# Inject browsingLevel (canonical integer) so the recipe's
# preview_nsfw_level can be set, enabling proper NSFW blur
# of the preview image. Fall back to nsfwLevel (string)
# when browsingLevel is absent.
if isinstance(metadata, dict):
browsing_level = image_info.get("browsingLevel")
nsfw_level_str = image_info.get("nsfwLevel")
if isinstance(browsing_level, int) and browsing_level > 0:
metadata["browsingLevel"] = browsing_level
elif (
isinstance(nsfw_level_str, str)
and nsfw_level_str
in (
"PG", "PG13", "R", "X", "XXX", "Blocked",
)
):
from ...utils.constants import NSFW_LEVELS
metadata["browsingLevel"] = NSFW_LEVELS.get(
nsfw_level_str, 0
)
# Validate that metadata contains meaningful recipe fields
# If not, treat as None to trigger EXIF extraction from downloaded image
@@ -171,12 +198,19 @@ class RecipeAnalysisService:
temp_path = self._create_temp_path(suffix=extension)
await self._download_image(url, temp_path)
if metadata is None and not is_video:
metadata = await asyncio.to_thread(
# Always extract EXIF from the downloaded image for generation
# params (prompt, negative prompt, sampler, steps, etc.).
# Previously this was gated on ``metadata is None``, but that
# skipped EXIF entirely when API metadata (modelVersionIds,
# browsingLevel) is present, losing all generation parameters.
exif_metadata = None
if not is_video:
exif_metadata = await asyncio.to_thread(
self._exif_utils.extract_image_metadata, temp_path
)
if not metadata and civitai_image_id and image_info:
# Fallback: try the original (non-optimized) image for EXIF data
if not exif_metadata and civitai_image_id and image_info:
original_url = image_info.get("url")
if original_url:
self._logger.debug(
@@ -187,15 +221,38 @@ class RecipeAnalysisService:
orig_temp_path = self._create_temp_path(suffix=".png")
try:
await self._download_image(original_url, orig_temp_path)
metadata = await asyncio.to_thread(
exif_metadata = await asyncio.to_thread(
self._exif_utils.extract_image_metadata,
orig_temp_path,
)
finally:
self._safe_cleanup(orig_temp_path)
# Parse EXIF data (typically a string like parameters/prompt/workflow)
# and API metadata (dict with modelVersionIds, browsingLevel) separately,
# then merge: API loras/checkpoint override, EXIF gen_params fill in gaps.
# This mirrors the two-pass approach in _do_import_from_url.
exif_parsed_result = None
if isinstance(exif_metadata, str):
exif_parser = self._recipe_parser_factory.create_parser(exif_metadata)
if exif_parser:
exif_data = await exif_parser.parse_metadata(
exif_metadata, recipe_scanner=recipe_scanner,
)
if exif_data and not exif_data.get("error"):
exif_parsed_result = exif_data
# Merge API metadata (dict) with EXIF data (if dict) for the
# CivitaiApiMetadataParser. If EXIF data is a string it was
# parsed above — don't try to merge a string into a dict.
merged = {}
if isinstance(exif_metadata, dict):
merged.update(exif_metadata)
if isinstance(metadata, dict):
merged.update(metadata)
result = await self._parse_metadata(
metadata or {},
merged,
recipe_scanner=recipe_scanner,
image_path=temp_path,
include_image_base64=True,
@@ -203,13 +260,23 @@ class RecipeAnalysisService:
extension=extension,
)
if civitai_image_id and image_info and not result.payload.get("error"):
mvid = image_info.get("modelVersionId")
if not mvid:
mvids = image_info.get("modelVersionIds")
if isinstance(mvids, list) and mvids:
mvid = mvids[0]
# Merge EXIF string-parsed gen_params into the API result.
# API gen_params take priority (they come later via update).
if exif_parsed_result and not result.payload.get("error"):
exif_gp = exif_parsed_result.get("gen_params") or {}
result_gp = result.payload.get("gen_params") or {}
merged_gp = {**exif_gp, **result_gp}
if merged_gp:
result.payload["gen_params"] = merged_gp
if civitai_image_id and image_info and not result.payload.get("error"):
# Use the metadata dict we built (may contain modelVersionIds
# and browsingLevel from the API root level). Do NOT pass
# image_info.get("meta") — it is null for images whose meta
# lives at the root level only. Also do NOT derive
# model_version_id from modelVersionIds[0] — that array mixes
# checkpoints, LoRAs, and other types without ordering
# guarantees; the parser already resolved them correctly.
recipe_for_enrich = {
"gen_params": result.payload.get("gen_params", {}),
"loras": result.payload.get("loras", []),
@@ -222,8 +289,10 @@ class RecipeAnalysisService:
recipe=recipe_for_enrich,
civitai_client=civitai_client,
request_params=None,
prefetched_civitai_meta_raw=image_info.get("meta"),
prefetched_model_version_id=mvid,
prefetched_civitai_meta_raw=(
metadata if isinstance(metadata, dict) else None
),
prefetched_model_version_id=None,
)
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
@@ -232,6 +301,12 @@ class RecipeAnalysisService:
if recipe_for_enrich.get("base_model"):
result.payload["base_model"] = recipe_for_enrich["base_model"]
# Extract browsingLevel from our constructed metadata for NSFW blur
if isinstance(metadata, dict):
bl = metadata.get("browsingLevel")
if isinstance(bl, int) and bl > 0:
result.payload["preview_nsfw_level"] = bl
return result
finally:
if temp_path:
@@ -314,6 +389,10 @@ class RecipeAnalysisService:
"prompt_type",
"positive",
"negative",
# modelVersionIds is injected at the root level by CivitAI's image
# API when meta is null. It carries the version IDs of ALL models
# (checkpoint + LoRAs) used to generate the image.
"modelVersionIds",
}
return any(field in metadata for field in recipe_fields)
+67 -37
View File
@@ -98,7 +98,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"lora_syntax_format": "legacy",
"model_card_footer_action": "replace_preview",
"show_version_on_card": True,
"update_flag_strategy": "same_base",
"version_grouping": "same_base",
"auto_organize_exclusions": [],
"metadata_refresh_skip_paths": [],
"skip_previously_downloaded_model_versions": False,
@@ -106,6 +106,12 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"backup_auto_enabled": True,
"backup_retention_count": 5,
"use_new_license_icons": True,
"group_by_model": False,
# AI / LLM provider configuration (BYOK)
"llm_provider": "openai", # "openai" | "ollama" | "custom"
"llm_api_key": "",
"llm_api_base": "", # empty = provider default
"llm_model": "", # e.g. "gpt-4o-mini"
}
@@ -744,6 +750,7 @@ class SettingsManager:
"includeTriggerWords": "include_trigger_words",
"compactMode": "compact_mode",
"modelCardFooterAction": "model_card_footer_action",
"update_flag_strategy": "version_grouping",
}
updated = False
@@ -871,6 +878,23 @@ class SettingsManager:
self.settings["civitai_api_key"] = env_api_key
self._save_settings()
# LLM provider overrides
llm_env_map = {
"LLM_API_KEY": "llm_api_key",
"LLM_MODEL": "llm_model",
"LLM_API_BASE": "llm_api_base",
"LLM_PROVIDER": "llm_provider",
}
llm_changed = False
for env_var, settings_key in llm_env_map.items():
env_val = os.environ.get(env_var)
if env_val:
logger.info("Found %s environment variable", env_var)
self.settings[settings_key] = env_val
llm_changed = True
if llm_changed:
self._save_settings()
def _default_settings_actions(self) -> List[Dict[str, Any]]:
return [
{
@@ -1566,7 +1590,7 @@ class SettingsManager:
previous_dir = os.path.dirname(previous_path) or target_dir
if os.path.abspath(previous_path) != os.path.abspath(target_path):
self._copy_model_cache_directory(previous_dir, target_dir)
self._migrate_settings_directory_content(previous_dir, target_dir)
logger.info("Switching settings file to: %s", target_path)
self._pending_portable_switch = {"other_path": other_path}
@@ -1601,46 +1625,52 @@ class SettingsManager:
finally:
self._pending_portable_switch = None
def _copy_model_cache_directory(self, source_dir: str, target_dir: str) -> None:
"""Copy model_cache artifacts when switching storage locations."""
def _migrate_settings_directory_content(
self, source_dir: str, target_dir: str
) -> None:
"""Migrate settings directory subdirectories when switching storage locations.
Copies the canonical subdirectories (cache, backups, logs, stats, wildcards)
from the old settings directory to the new one. Legacy cache artifacts
(model_cache, recipe_cache, etc.) are migrated lazily by
``resolve_cache_path_with_migration`` on first access.
Args:
source_dir: The previous settings directory path.
target_dir: The new settings directory path.
"""
if not source_dir or not target_dir:
return
source_cache_dir = os.path.join(source_dir, "model_cache")
target_cache_dir = os.path.join(target_dir, "model_cache")
if os.path.isdir(source_cache_dir) and os.path.abspath(
source_cache_dir
) != os.path.abspath(target_cache_dir):
try:
shutil.copytree(
source_cache_dir,
target_cache_dir,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
)
except Exception as exc:
logger.warning(
"Failed to copy model_cache directory from %s to %s: %s",
source_cache_dir,
target_cache_dir,
exc,
)
def _copy_dir(name: str) -> None:
source = os.path.join(source_dir, name)
target = os.path.join(target_dir, name)
if os.path.isdir(source) and os.path.abspath(source) != os.path.abspath(
target
):
try:
shutil.copytree(
source,
target,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
)
except Exception as exc:
logger.warning(
"Failed to copy directory %s from %s to %s: %s",
name,
source,
target,
exc,
)
source_cache_file = os.path.join(source_dir, "model_cache.sqlite")
target_cache_file = os.path.join(target_dir, "model_cache.sqlite")
if os.path.isfile(source_cache_file) and os.path.abspath(
source_cache_file
) != os.path.abspath(target_cache_file):
try:
shutil.copy2(source_cache_file, target_cache_file)
except Exception as exc:
logger.warning(
"Failed to copy model_cache.sqlite from %s to %s: %s",
source_cache_file,
target_cache_file,
exc,
)
# Managed subdirectories under settings_dir
_copy_dir("cache")
_copy_dir("backups")
_copy_dir("logs")
_copy_dir("stats")
_copy_dir("wildcards")
def _get_user_config_directory(self) -> str:
"""Return the user configuration directory, falling back to ~/.config."""
+17
View File
@@ -47,6 +47,20 @@ SUPPORTED_MEDIA_EXTENSIONS = {
"videos": [".mp4", ".webm"],
}
# Model weight file extensions recognised by scanners.
# This is the union of all scanner extensions (lora, checkpoint, embedding).
MODEL_FILE_EXTENSIONS = {
".safetensors",
".ckpt",
".pt",
".pt2",
".bin",
".pth",
".pkl",
".sft",
".gguf",
}
# Valid sub-types for each scanner type
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
@@ -147,6 +161,8 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
"Qwen",
"ZImageBase",
"ZImageTurbo",
# Krea 2 — loaded via UNETLoader in ComfyUI
"Krea 2",
]
)
@@ -213,5 +229,6 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
"Ernie",
"Ernie Turbo",
"Nucleus",
"Krea 2",
]
)
+6
View File
@@ -35,6 +35,9 @@ class BaseModelMetadata:
metadata_source: Optional[str] = None # Last provider that supplied metadata
last_checked_at: float = 0 # Last checked timestamp
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
trainedWords: List[str] = field(
default_factory=list
) # Trigger words / activation prompts (source-agnostic)
_unknown_fields: Dict[str, Any] = field(
default_factory=dict, repr=False, compare=False
) # Store unknown fields
@@ -47,6 +50,9 @@ class BaseModelMetadata:
if self.tags is None:
self.tags = []
if self.trainedWords is None:
self.trainedWords = []
@classmethod
def from_dict(cls, data: Dict) -> "BaseModelMetadata":
"""Create instance from dictionary"""
+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.4"
version = "1.1.6"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
-134
View File
@@ -1,134 +0,0 @@
{
"id": 1746460,
"name": "Mixplin Style [Illustrious]",
"type": "LORA",
"description": "description",
"username": "Ty_Lee",
"downloadCount": 4207,
"favoriteCount": 0,
"commentCount": 8,
"ratingCount": 0,
"rating": 0,
"is_nsfw": true,
"nsfw_level": 31,
"createdAt": "2025-07-06T01:51:42.859Z",
"updatedAt": "2025-10-10T23:15:26.714Z",
"deletedAt": null,
"tags": [
"art",
"style",
"artist style",
"styles",
"mixplin",
"artiststyle"
],
"creator_id": "Ty_Lee",
"creator_username": "Ty_Lee",
"creator_name": "Ty_Lee",
"creator_url": "/users/Ty_Lee",
"versions": [
{
"id": 2042594,
"name": "v2.0",
"href": "/models/1746460?modelVersionId=2042594"
},
{
"id": 1976567,
"name": "v1.0",
"href": "/models/1746460?modelVersionId=1976567"
}
],
"version": {
"id": 1976567,
"modelId": 1746460,
"name": "v1.0",
"baseModel": "Illustrious",
"baseModelType": "Standard",
"description": null,
"downloadCount": 437,
"ratingCount": 0,
"rating": 0,
"is_nsfw": true,
"nsfw_level": 31,
"createdAt": "2025-07-05T10:17:28.716Z",
"updatedAt": "2025-10-10T23:15:26.756Z",
"deletedAt": null,
"files": [
{
"id": 1874043,
"name": "mxpln-illustrious-ty_lee.safetensors",
"type": "Model",
"sizeKB": 223124.37109375,
"downloadUrl": "https://civitai.com/api/download/models/1976567",
"modelId": 1746460,
"modelName": "Mixplin Style [Illustrious]",
"modelVersionId": 1976567,
"is_nsfw": true,
"nsfw_level": 31,
"sha256": "e2b7a280d6539556f23f380b3f71e4e22bc4524445c4c96526e117c6005c6ad3",
"createdAt": "2025-07-05T10:17:28.716Z",
"updatedAt": "2025-10-10T23:15:26.766Z",
"is_primary": false,
"mirrors": [
{
"filename": "mxpln-illustrious-ty_lee.safetensors",
"url": "https://civitai.com/api/download/models/1976567",
"source": "civitai",
"model_id": 1746460,
"model_version_id": 1976567,
"deletedAt": null,
"is_gated": false,
"is_paid": false
}
]
}
],
"images": [
{
"id": 86403595,
"url": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
"nsfwLevel": 1,
"width": 1560,
"height": 2280,
"hash": "U7G8Zp0w02%IA6%N00-;D]-W~VNG0nMw-.IV",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null,
"image_url": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
"link": "https://genur.art/posts/86403595"
}
],
"trigger": [
"mxpln"
],
"allow_download": true,
"download_url": "/api/download/models/1976567",
"platform_url": "https://civitai.com/models/1746460?modelVersionId=1976567",
"civitai_model_id": 1746460,
"civitai_model_version_id": 1976567,
"href": "/models/1746460?modelVersionId=1976567",
"mirrors": [
{
"platform": "tensorart",
"href": "/tensorart/models/904473536033245448/versions/904473536033245448",
"platform_url": "https://tensor.art/models/904473536033245448",
"name": "Mixplin Style MXP",
"version_name": "Mixplin",
"id": "904473536033245448",
"version_id": "904473536033245448"
}
]
},
"platform": "civitai",
"platform_name": "CivitAI",
"meta": {
"title": "Mixplin Style [Illustrious] - v1.0 - CivitAI Archive",
"description": "Mixplin Style [Illustrious] v1.0 is a Illustrious LORA AI model created by Ty_Lee for generating images of art, style, artist style, styles, mixplin, artiststyle",
"image": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
"canonical": "https://civarchive.com/models/1746460?modelVersionId=1976567"
}
}
-38
View File
@@ -1,38 +0,0 @@
CREATE TABLE models (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
username TEXT,
data TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE TABLE model_versions (
id INTEGER PRIMARY KEY,
model_id INTEGER NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
base_model TEXT NOT NULL,
published_at INTEGER,
data TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE INDEX model_versions_model_id_idx ON model_versions (model_id);
CREATE TABLE model_files (
id INTEGER PRIMARY KEY,
model_id INTEGER NOT NULL,
version_id INTEGER NOT NULL,
type TEXT NOT NULL,
sha256 TEXT,
data TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE INDEX model_files_model_id_idx ON model_files (model_id);
CREATE INDEX model_files_version_id_idx ON model_files (version_id);
CREATE TABLE archived_model_files (
file_id INTEGER PRIMARY KEY,
model_id INTEGER NOT NULL,
version_id INTEGER NOT NULL
) STRICT;
-110
View File
@@ -1,110 +0,0 @@
{
"id": 1231067,
"name": "Vivid Impressions Storybook Style",
"description": "<h3 id=\"if-you'd-like-to-support-me-feel-free-to-visit-my-ko-fi-page.-please-share-your-images-using-the-&quot;+add-post&quot;-button-below.-it-supports-the-creators.-thanks!-nnfwkvfly\">If you'd like to support me, feel free to visit my <a target=\"_blank\" rel=\"ugc\" href=\"https://ko-fi.com/pixelpawsai\">Ko-Fi</a> page. ❤️<br /><br />Please share your images using the \"<span style=\"color:rgb(250, 82, 82)\">+add post</span>\" button below. It supports the creators. Thanks! 💕</h3><h3 id=\"if-you-like-my-lora-please-like-comment-or-donate-some-buzz.-much-appreciated!-vyeqok3go\">If you like my LoRA, please<span style=\"color:rgb(230, 73, 128)\"> </span><span style=\"color:rgb(250, 82, 82)\">like</span>, <span style=\"color:rgb(250, 82, 82)\">comment</span>, or <span style=\"color:#fa5252\">donate some Buzz</span>. Much appreciated! ❤️</h3><h3 id=\"-lo912t8rj\"></h3><h3 id=\"trigger-word:-ppstorybook-wlggllim2\"><strong><span style=\"color:rgb(253, 126, 20)\">Trigger word: </span></strong>ppstorybook</h3><h3 id=\"strength:-0.8-experiment-as-you-like-luvhks6za\"><strong><span style=\"color:rgb(253, 126, 20)\">Strength: </span></strong>0.8, experiment as you like</h3>",
"allowNoCredit": true,
"allowCommercialUse": [
"Image",
"RentCivit",
"Rent",
"Sell"
],
"allowDerivatives": true,
"allowDifferentLicense": true,
"type": "LORA",
"minor": false,
"sfwOnly": false,
"poi": false,
"nsfw": false,
"nsfwLevel": 1,
"availability": "Public",
"cosmetic": null,
"supportsGeneration": true,
"stats": {
"downloadCount": 2183,
"favoriteCount": 0,
"thumbsUpCount": 416,
"thumbsDownCount": 0,
"commentCount": 12,
"ratingCount": 0,
"rating": 0,
"tippedAmountCount": 360
},
"creator": {
"username": "PixelPawsAI",
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/f3a1aa7c-0159-4dd8-884a-1e7ceb350f96/width=96/PixelPawsAI.jpeg"
},
"tags": [
"style",
"illustration",
"storybook"
],
"modelVersions": [
{
"id": 1387174,
"index": 0,
"name": "v1.0",
"baseModel": "Flux.1 D",
"baseModelType": "Standard",
"createdAt": "2025-02-08T11:15:47.197Z",
"publishedAt": "2025-02-08T11:29:04.487Z",
"status": "Published",
"availability": "Public",
"nsfwLevel": 1,
"trainedWords": [
"ppstorybook"
],
"covered": true,
"stats": {
"downloadCount": 2183,
"ratingCount": 0,
"rating": 0,
"thumbsUpCount": 416,
"thumbsDownCount": 0
},
"files": [
{
"id": 1289799,
"sizeKB": 18829.1484375,
"name": "pp-storybook_rank2_bf16.safetensors",
"type": "Model",
"pickleScanResult": "Success",
"pickleScanMessage": "No Pickle imports",
"virusScanResult": "Success",
"virusScanMessage": null,
"scannedAt": "2025-02-08T11:21:04.247Z",
"metadata": {
"format": "SafeTensor"
},
"hashes": {
"AutoV1": "F414C813",
"AutoV2": "9753338AB6",
"SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668",
"CRC32": "A65AE7B3",
"BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591",
"AutoV3": "34A22376739D"
},
"downloadUrl": "https://civitai.com/api/download/models/1387174",
"primary": true
}
],
"images": [
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/original=true/56547310.jpeg",
"nsfwLevel": 1,
"width": 832,
"height": 1216,
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
}
],
"downloadUrl": "https://civitai.com/api/download/models/1387174"
}
]
}
-100
View File
@@ -1,100 +0,0 @@
{
"id": 1387174,
"modelId": 1231067,
"name": "v1.0",
"createdAt": "2025-02-08T11:15:47.197Z",
"updatedAt": "2025-02-08T11:29:04.526Z",
"status": "Published",
"publishedAt": "2025-02-08T11:29:04.487Z",
"trainedWords": [
"ppstorybook"
],
"trainingStatus": null,
"trainingDetails": null,
"baseModel": "Flux.1 D",
"baseModelType": null,
"earlyAccessEndsAt": null,
"earlyAccessConfig": null,
"description": null,
"uploadType": "Created",
"usageControl": "Download",
"air": "urn:air:flux1:lora:civitai:1231067@1387174",
"stats": {
"downloadCount": 1436,
"ratingCount": 0,
"rating": 0,
"thumbsUpCount": 316
},
"model": {
"name": "Vivid Impressions Storybook Style",
"type": "LORA",
"nsfw": false,
"poi": false
},
"files": [
{
"id": 1289799,
"sizeKB": 18829.1484375,
"name": "pp-storybook_rank2_bf16.safetensors",
"type": "Model",
"pickleScanResult": "Success",
"pickleScanMessage": "No Pickle imports",
"virusScanResult": "Success",
"virusScanMessage": null,
"scannedAt": "2025-02-08T11:21:04.247Z",
"metadata": {
"format": "SafeTensor",
"size": null,
"fp": null
},
"hashes": {
"AutoV1": "F414C813",
"AutoV2": "9753338AB6",
"SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668",
"CRC32": "A65AE7B3",
"BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591",
"AutoV3": "34A22376739D"
},
"primary": true,
"downloadUrl": "https://civitai.com/api/download/models/1387174"
}
],
"images": [
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/width=832/56547310.jpeg",
"nsfwLevel": 1,
"width": 832,
"height": 1216,
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
"type": "image",
"metadata": {
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
"size": 1361590,
"width": 832,
"height": 1216
},
"meta": {
"Size": "832x1216",
"seed": 1116375220995209,
"Model": "flux_dev_fp8",
"steps": 23,
"hashes": {
"model": ""
},
"prompt": "ppstorybook,A dreamy bunny hopping across a rainbow bridge, with fluffy clouds surrounding it and tiny birds flying alongside, rendered in a magical, soft-focus style with pastel hues and glowing accents.",
"Version": "ComfyUI",
"sampler": "DPM++ 2M",
"cfgScale": 3.5,
"clipSkip": 1,
"resources": [],
"Model hash": ""
},
"availability": "Public",
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
}
],
"downloadUrl": "https://civitai.com/api/download/models/1387174"
}
-153
View File
@@ -1,153 +0,0 @@
{
"resource-stack": {
"class_type": "CheckpointLoaderSimple",
"inputs": { "ckpt_name": "urn:air:sdxl:checkpoint:civitai:827184@1410435" }
},
"resource-stack-1": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "urn:air:sdxl:lora:civitai:1107767@1253442",
"strength_model": 1,
"strength_clip": 1,
"model": ["resource-stack", 0],
"clip": ["resource-stack", 1]
}
},
"resource-stack-2": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "urn:air:sdxl:lora:civitai:1342708@1516344",
"strength_model": 1,
"strength_clip": 1,
"model": ["resource-stack-1", 0],
"clip": ["resource-stack-1", 1]
}
},
"resource-stack-3": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "urn:air:sdxl:lora:civitai:122359@135867",
"strength_model": 1.55,
"strength_clip": 1,
"model": ["resource-stack-2", 0],
"clip": ["resource-stack-2", 1]
}
},
"6": {
"class_type": "smZ CLIPTextEncode",
"inputs": {
"text": "masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking",
"parser": "comfy",
"text_g": "",
"text_l": "",
"ascore": 2.5,
"width": 0,
"height": 0,
"crop_w": 0,
"crop_h": 0,
"target_width": 0,
"target_height": 0,
"smZ_steps": 1,
"mean_normalization": true,
"multi_conditioning": true,
"use_old_emphasis_implementation": false,
"with_SDXL": false,
"clip": ["resource-stack-3", 1]
},
"_meta": { "title": "Positive" }
},
"7": {
"class_type": "smZ CLIPTextEncode",
"inputs": {
"text": "bad quality,worst quality,worst detail,sketch,censor",
"parser": "comfy",
"text_g": "",
"text_l": "",
"ascore": 2.5,
"width": 0,
"height": 0,
"crop_w": 0,
"crop_h": 0,
"target_width": 0,
"target_height": 0,
"smZ_steps": 1,
"mean_normalization": true,
"multi_conditioning": true,
"use_old_emphasis_implementation": false,
"with_SDXL": false,
"clip": ["resource-stack-3", 1]
},
"_meta": { "title": "Negative" }
},
"20": {
"class_type": "UpscaleModelLoader",
"inputs": { "model_name": "urn:air:other:upscaler:civitai:147759@164821" },
"_meta": { "title": "Load Upscale Model" }
},
"17": {
"class_type": "LoadImage",
"inputs": {
"image": "https://orchestration.civitai.com/v2/consumer/blobs/5KZ6358TW8CNEGPZKD08NVDB30",
"upload": "image"
},
"_meta": { "title": "Image Load" }
},
"19": {
"class_type": "ImageUpscaleWithModel",
"inputs": { "upscale_model": ["20", 0], "image": ["17", 0] },
"_meta": { "title": "Upscale Image (using Model)" }
},
"23": {
"class_type": "ImageScale",
"inputs": {
"upscale_method": "nearest-exact",
"crop": "disabled",
"width": 1280,
"height": 1856,
"image": ["19", 0]
},
"_meta": { "title": "Upscale Image" }
},
"21": {
"class_type": "VAEEncode",
"inputs": { "pixels": ["23", 0], "vae": ["resource-stack", 2] },
"_meta": { "title": "VAE Encode" }
},
"11": {
"class_type": "KSampler",
"inputs": {
"sampler_name": "euler_ancestral",
"scheduler": "normal",
"seed": 2088370631,
"steps": 47,
"cfg": 6.5,
"denoise": 0.3,
"model": ["resource-stack-3", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["21", 0]
},
"_meta": { "title": "KSampler" }
},
"13": {
"class_type": "VAEDecode",
"inputs": { "samples": ["11", 0], "vae": ["resource-stack", 2] },
"_meta": { "title": "VAE Decode" }
},
"12": {
"class_type": "SaveImage",
"inputs": { "filename_prefix": "ComfyUI", "images": ["13", 0] },
"_meta": { "title": "Save Image" }
},
"extra": {
"airs": [
"urn:air:other:upscaler:civitai:147759@164821",
"urn:air:sdxl:checkpoint:civitai:827184@1410435",
"urn:air:sdxl:lora:civitai:1107767@1253442",
"urn:air:sdxl:lora:civitai:1342708@1516344",
"urn:air:sdxl:lora:civitai:122359@135867"
]
},
"extraMetadata": "{\u0022prompt\u0022:\u0022masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking\u0022,\u0022negativePrompt\u0022:\u0022bad quality,worst quality,worst detail,sketch,censor\u0022,\u0022steps\u0022:47,\u0022cfgScale\u0022:6.5,\u0022sampler\u0022:\u0022euler_ancestral\u0022,\u0022workflowId\u0022:\u0022img2img-hires\u0022,\u0022resources\u0022:[{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1253442,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1516344,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:135867,\u0022strength\u0022:1.55}],\u0022remixOfId\u0022:32140259}"
}
@@ -1,18 +0,0 @@
a dynamic and dramatic digital artwork featuring a stylized anthropomorphic white tiger with striking yellow eyes. The tiger is depicted in a powerful stance, wielding a katana with one hand raised above its head. Its fur is detailed with black stripes, and its mane flows wildly, blending with the stormy background. The scene is set amidst swirling dark clouds and flashes of lightning, enhancing the sense of movement and energy. The composition is vertical, with the tiger positioned centrally, creating a sense of depth and intensity. The color palette is dominated by shades of blue, gray, and white, with bright highlights from the lightning. The overall style is reminiscent of fantasy or manga art, with a focus on dynamic action and dramatic lighting.
Negative prompt:
Steps: 30, Sampler: Undefined, CFG scale: 3.5, Seed: 90300501, Size: 832x1216, Clip skip: 2, Created Date: 2025-03-05T13:51:18.1770234Z, Civitai resources: [{"type":"checkpoint","modelVersionId":691639,"modelName":"FLUX","modelVersionName":"Dev"},{"type":"lora","weight":0.4,"modelVersionId":1202162,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Gothic Lines"},{"type":"lora","weight":0.8,"modelVersionId":1470588,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Retro"},{"type":"lora","weight":0.75,"modelVersionId":746484,"modelName":"Elden Ring - Yoshitaka Amano","modelVersionName":"V1"},{"type":"lora","weight":0.2,"modelVersionId":914935,"modelName":"Ink-style","modelVersionName":"ink-dynamic"},{"type":"lora","weight":0.2,"modelVersionId":1189379,"modelName":"Painterly Fantasy by ChronoKnight - [FLUX \u0026 IL]","modelVersionName":"FLUX"},{"type":"lora","weight":0.2,"modelVersionId":757030,"modelName":"Mezzotint Artstyle for Flux - by Ethanar","modelVersionName":"V1"}], Civitai metadata: {}
masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject,
dynamic angle, dutch angle, from below, epic half body portrait, gritty, wabi sabi, looking at viewer, woman is a geisha, parted lips,
holographic skin, holofoil glitter, faint, glowing, ethereal, neon hair, glowing hair, otherworldly glow, she is dangerous
<lora:ck-shadow-circuit-IL:0.78>, <lora:ck-nc-cyberpunk-IL-000011:0.4>, <lora:ck-neon-retrowave-IL:0.2>, <lora:ck-yoneyama-mai-IL-000014:0.4>
Negative prompt: score_6, score_5, score_4, bad quality, worst quality, worst detail, sketch, censorship, furry, window, headphones,
Steps: 30, Sampler: Euler a, Schedule type: Simple, CFG scale: 7, Seed: 1405717592, Size: 832x1216, Model hash: 1ad6ca7f70, Model: waiNSFWIllustrious_v100, Denoising strength: 0.35, Hires CFG Scale: 5, Hires upscale: 1.3, Hires steps: 20, Hires upscaler: 4x-AnimeSharp, Lora hashes: "ck-shadow-circuit-IL: 88e247aa8c3d, ck-nc-cyberpunk-IL-000011: 935e6755554c, ck-neon-retrowave-IL: edafb9df7da1, ck-yoneyama-mai-IL-000014: 1b9305692a2e", Version: f2.0.1v1.10.1-1.10.1, Diffusion in Low Bits: Automatic (fp16 LoRA)
Masterpiece, best quality, high quality, newest, highres, 8K, HDR, absurdres, 1girl, solo, futuristic warrior, sleek exosuit with glowing energy cores, long braided hair flowing behind, gripping a high-tech bow with an energy arrow drawn, standing on a floating platform overlooking a massive space station, planets and nebulae in the distance, soft glow from distant stars, cinematic depth, foreshortening, dynamic pose, dramatic sci-fi lighting.
Negative prompt: worst quality, normal quality, anatomical nonsense, bad anatomy,interlocked fingers, extra fingers,watermark,simple background, loli,
Steps: 20, Sampler: euler_ancestral_karras, CFG scale: 8.0, Seed: 691121152183439, Model: il\waiNSFWIllustrious_v110.safetensors, Model hash: c3688ee04c, Lora_0 Model name: iLLMythAn1m3Style.safetensors, Lora_0 Model hash: ba7a040786, Lora_0 Strength model: 1.0, Lora_0 Strength clip: 1.0, Hashes: {"model": "c3688ee04c", "lora:iLLMythAn1m3Style": "ba7a040786"}
Immerse yourself in the enchanting journey, where harmonious transmutation of Bauhaus art unites photographic precision and contemporary illustration, capturing an enthralling blend between vivid abstract nature and urban landscapes. Let your eyes be captivated by a kaleidoscope of rich, deep reds and yellows, entwined with intriguing shades that beckon a somber atmosphere. As your spirit ventures along this haunting path, witness the mysterious, high-angle perspective dominated by scattered clouds granting you a mesmerizing glimpse into the ever-transforming realm of metamorphosing environments. ,<lora:flux/fav/ck-charcoal-drawing-000014.safetensors:1.0:1.0>
Negative prompt:
Steps: 20, Sampler: Euler, CFG scale: 3.5, Seed: 885491426361006, Size: 832x1216, Model hash: 4610115bb0, Model: flux_dev, Hashes: {"LORA:flux/fav/ck-charcoal-drawing-000014.safetensors": "34d36c17c1", "model": "4610115bb0"}, Version: ComfyUI
-3
View File
@@ -1,3 +0,0 @@
In this ethereal masterpiece, metallic sculptures juxtapose effortlessly against a subtle backdrop of misty neutral hues. Exquisite curvatures and geometric shapes converge harmoniously, creating an illuminating realm of polished metallic surfaces. Shimmering copper, gleaming silver, and lustrous gold hues dance in perfect balance, highlighting the intricate play of light and shadow cast upon these celestial forms. A halo of diffused radiance envelops each piece, enhancing their textured depths and metallic brilliance while allowing delicate details to emerge from obscurity. The composition conveys a serene yet mesmerizing atmosphere, as if suspended in a dreamlike limbo between reality and fantasy. The tantalizing interplay of colors within this transcendent realm creates a profound sense of depth and grandeur that invites the viewer into an enchanting voyage through abstract metallic beauty. This captivating artwork evokes emotions of boundless curiosity and reverence reminiscent of the timeless works by artists such as Giorgio de Chirico or Paul Klee, while asserting a unique, modern artistic sensibility. With every observation, a new nuance unfolds, as if a never-ending story waiting to be discovered through the lens of metallic artistry.
Negative prompt:
Steps: 25, Sampler: dpmpp_2m_sgm_uniform, Seed: 471889513588087, Model: Fluxmania V5P.safetensors, Model hash: 8ae0583b06, VAE: ae.sft, VAE hash: afc8e28272, Lora_0 Model name: ArtVador I.safetensors, Lora_0 Model hash: 08f7133a58, Lora_0 Strength model: 0.65, Lora_0 Strength clip: 0.65, Lora_1 Model name: Kaoru Yamada.safetensors, Lora_1 Model hash: d4893f7202, Lora_1 Strength model: 0.75, Lora_1 Strength clip: 0.75, Hashes: {"model": "8ae0583b06", "vae": "afc8e28272", "lora:ArtVador I": "08f7133a58", "lora:Kaoru Yamada": "d4893f7202"}
-33
View File
@@ -1,33 +0,0 @@
{
"id": "42803a29-02dc-49e1-b798-27da70e8b408",
"file_path": "/home/miao/workspace/ComfyUI/models/loras/recipes/test/42803a29-02dc-49e1-b798-27da70e8b408.webp",
"title": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect",
"modified": 1754897325.0507245,
"created_date": 1754897325.0507245,
"base_model": "Illustrious",
"loras": [
{
"file_name": "",
"hash": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a",
"strength": 1.0,
"modelVersionId": 2007092,
"modelName": "Pony: People's Works +",
"modelVersionName": "v8_Illusv1.0",
"isDeleted": false,
"exclude": false
}
],
"gen_params": {
"prompt": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect eyes, realistic eyes,\n(flat colors:1.5), (anime:1.5), (lineart:1.5),\nclose-up, solo, tongue, 1girl, food, (saliva:0.1), open mouth, candy, simple background, blue background, large lollipop, tongue out, fade background, lips, hand up, holding, looking at viewer, licking, seductive, half-closed eyes,",
"negative_prompt": "shiny skin,",
"steps": 19,
"sampler": "Euler a",
"cfg_scale": 5,
"seed": 1765271748,
"size": "832x1216",
"clip_skip": 2
},
"fingerprint": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a:1.0",
"source_path": "https://civitai.com/images/92427432",
"folder": "test"
}
-42
View File
@@ -1,42 +0,0 @@
{
"id": 2269146,
"modelId": 2004760,
"name": "v1.0 Illustrious",
"nsfwLevel": 1,
"trainedWords": ["PencilSketchDaal"],
"baseModel": "Illustrious",
"description": "<p>Illustrious. Your pencil may vary with your checkpoint. </p>",
"model": {
"name": "Pencil Sketch Anime",
"type": "LORA",
"nsfw": false,
"description": "description",
"tags": ["style"],
"allowNoCredit": true,
"allowCommercialUse": ["Sell"],
"allowDerivatives": true,
"allowDifferentLicense": true
},
"files": [
{
"id": 2161260,
"sizeKB": 223106.37890625,
"name": "Pencil-Sketch-Illustrious.safetensors",
"type": "Model",
"hashes": {
"SHA256": "2C70479CD673B0FE056EAF4FD97C7F33A39F14853805431AC9AB84226ECE3B82"
},
"primary": true,
"downloadUrl": "https://civitai.com/api/download/models/2269146",
"mirrors": {}
}
],
"images": [
{},
{}
],
"creator": {
"username": "Daalis",
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/eb245b49-edc8-4ed6-ad7b-6d61eb8c51de/width=96/Daalis.jpeg"
}
}
-91
View File
@@ -1,91 +0,0 @@
{
"id": 1255556,
"modelId": 1117241,
"name": "v1.0",
"createdAt": "2025-01-08T06:13:08.839Z",
"updatedAt": "2025-01-08T06:28:54.156Z",
"status": "Published",
"publishedAt": "2025-01-08T06:28:54.155Z",
"trainedWords": ["in the style of ppWhimsy"],
"trainingStatus": null,
"trainingDetails": null,
"baseModel": "Flux.1 D",
"baseModelType": "Standard",
"earlyAccessEndsAt": null,
"earlyAccessConfig": null,
"description": null,
"uploadType": "Created",
"usageControl": "Download",
"air": "urn:air:flux1:lora:civitai:1117241@1255556",
"stats": {
"downloadCount": 210,
"ratingCount": 0,
"rating": 0,
"thumbsUpCount": 26
},
"model": {
"name": "Enchanted Whimsy style (Flux)",
"type": "LORA",
"nsfw": false,
"poi": false
},
"files": [
{
"id": 1160774,
"sizeKB": 38828.8125,
"name": "pp-enchanted-whimsy.safetensors",
"type": "Model",
"pickleScanResult": "Success",
"pickleScanMessage": "No Pickle imports",
"virusScanResult": "Success",
"virusScanMessage": null,
"scannedAt": "2025-01-08T06:16:27.731Z",
"metadata": {
"format": "SafeTensor",
"size": null,
"fp": null
},
"hashes": {
"AutoV1": "40CAF049",
"AutoV2": "3202778C3E",
"SHA256": "3202778C3EBE5CF7EBE5FC51561DEAE8611F4362036EB7C02EFA033C705E6240",
"CRC32": "69DCD953",
"BLAKE3": "ED04580DDB1AD36D8B87F4B0800F5930C7E5D4A7269BDC2BE26ED77EA1A34697",
"AutoV3": "BF82986F8597"
},
"primary": true,
"downloadUrl": "https://civitai.com/api/download/models/1255556"
}
],
"images": [
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/707aef9b-36fb-46c2-ac41-adcab539d3a6/width=832/50270101.jpeg",
"nsfwLevel": 1,
"width": 832,
"height": 1216,
"hash": "U7Am@@$^J3%100R;pLR.M]tQ-ps+?wRiVrof",
"type": "image",
"metadata": {
"hash": "U7Am@@$^J3%100R;pLR.M]tQ-ps+?wRiVrof",
"size": 702313,
"width": 832,
"height": 1216
},
"minor": false,
"poi": false,
"meta": {
"prompt": "in the style of ppWhimsy, a close-up of a boy with a crown of ferns and tiny horns, his eyes wide with wonder as a family of glowing hedgehogs nestle in his hands, their spines shimmering with soft pastel colors"
},
"availability": "Public",
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
}
],
"downloadUrl": "https://civitai.com/api/download/models/1255556",
"creator": {
"username": "PixelPawsAI",
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/f3a1aa7c-0159-4dd8-884a-1e7ceb350f96/width=96/PixelPawsAI.jpeg"
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
/* Style for selected cards */
.model-card.selected {
box-shadow: 0 0 0 2px var(--lora-accent);
outline: 2px solid var(--lora-accent);
outline-offset: -2px;
position: relative;
}
+59
View File
@@ -509,6 +509,50 @@
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
}
/* Clickable version count link (shown in group-by-model mode) */
.version-count-link {
display: inline-block;
color: var(--color-accent);
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
font-size: 0.85em;
line-height: 1.4;
margin-top: 2px;
border: 1px solid var(--color-accent-border);
border-radius: var(--border-radius-xs);
padding: 1px 6px;
background: var(--color-accent-subtle);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.version-count-link:hover {
background: var(--color-accent-border);
border-color: var(--color-accent-transparent);
}
/* Medium density adjustments for version count link */
.medium-density .version-count-link {
font-size: 0.8em;
}
.medium-density .badge-version-unit .version-count-link {
max-width: 90px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Compact density adjustments for version count link */
.compact-density .version-count-link {
font-size: 0.75em;
}
.compact-density .badge-version-unit .version-count-link {
max-width: 70px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Version row — flex container for badges + version names */
.version-row {
display: flex;
@@ -690,6 +734,21 @@ body.hide-card-version .hl-badge {
}
}
/* Grid-scoped loading overlay (replaces full-page overlay for VirtualScroller refreshes) */
.grid-loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--lora-bg-transparent, oklch(0% 0 0 / 0.3));
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
pointer-events: none;
}
/* Add after the existing .model-card:hover styles */
@keyframes update-pulse {
+76 -1
View File
@@ -149,7 +149,7 @@
width: 100%;
padding: 0.5rem 0.75rem;
padding-left: 2.25rem !important;
padding-right: 5rem !important;
padding-right: 6.75rem !important; /* clear room for options + filter + clear/cue toggles */
border: none;
background: transparent;
color: var(--text-color);
@@ -190,6 +190,81 @@
right: 2.25rem;
}
/* Clear button: sit immediately left of the search-options toggle */
.header-search .search-clear {
position: absolute;
right: 4.25rem; /* 2.25rem (options toggle) + 28px toggle width + 4px gap */
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
display: none;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--border-radius-xs, 4px);
padding: 0;
line-height: 1;
transition: background-color var(--transition-base), color var(--transition-base);
}
.header-search .search-clear.visible {
display: flex;
}
.header-search .search-clear:hover {
background: color-mix(in oklch, var(--text-muted) 15%, transparent);
color: var(--lora-accent);
}
/* Keyboard shortcut cue: shown when search is empty, hidden when typing */
.header-search .search-shortcut-cue {
position: absolute;
right: 4.25rem; /* same slot as clear button */
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 2px;
pointer-events: none;
font-family: inherit;
font-size: 0.7rem;
line-height: 1;
color: var(--text-muted);
opacity: 0.7;
white-space: nowrap;
transition: opacity 0.2s ease;
}
.header-search .search-shortcut-cue kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
font-family: inherit;
font-size: 0.68rem;
font-weight: 500;
color: var(--text-muted);
/* Subtle tint derived from text color so it adapts to both light & dark themes */
background: color-mix(in oklch, var(--text-muted) 12%, transparent);
border: 1px solid color-mix(in oklch, var(--text-muted) 25%, transparent);
border-radius: var(--border-radius-xs, 3px);
line-height: 1;
}
.header-search .search-shortcut-cue.hidden {
display: none;
}
.header-search.disabled .search-shortcut-cue {
display: none;
}
.header-search .search-options-toggle:hover,
.header-search .search-filter-toggle:hover,
.header-search .search-filter-toggle:focus-visible {
+150 -5
View File
@@ -444,16 +444,161 @@
flex: 1;
}
.base-model-selector {
width: 100%;
padding: 3px 5px;
/* ── Base Model Search Dropdown ─────────────────────────────────────────── */
.base-model-search-wrapper {
position: relative;
flex: 1;
min-width: 0;
z-index: 100;
}
.base-model-search-input-wrapper {
display: flex;
align-items: center;
background: var(--bg-color);
border: 1px solid var(--lora-accent);
border-radius: var(--border-radius-xs);
padding: 0 6px;
gap: 4px;
}
.base-model-search-input-wrapper .search-icon {
color: var(--text-color);
opacity: 0.45;
font-size: 12px;
flex-shrink: 0;
pointer-events: none;
/* Reset global .search-icon rules from search-filter.css */
position: static;
right: auto;
top: auto;
transform: none;
}
.base-model-search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-color);
font-size: 0.9em;
outline: none;
margin-right: var(--space-1);
padding: 3px 0;
width: 100%;
min-width: 0;
}
.base-model-search-input::placeholder {
color: var(--text-color);
opacity: 0.35;
}
.base-model-dropdown {
position: absolute;
top: 100%;
left: -1px;
right: -1px;
max-height: 270px;
overflow-y: auto;
background: var(--bg-color);
border: 1px solid var(--lora-border);
border-top: none;
border-radius: 0 0 var(--border-radius-xs) var(--border-radius-xs);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22);
z-index: 101;
}
[data-theme="dark"] .base-model-dropdown {
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
}
/* Dropdown scrollbar styling */
.base-model-dropdown::-webkit-scrollbar {
width: 6px;
}
.base-model-dropdown::-webkit-scrollbar-thumb {
background: var(--lora-border);
border-radius: 3px;
}
.base-model-dropdown::-webkit-scrollbar-track {
background: transparent;
}
/* Section */
.base-model-dropdown-section {
border-bottom: 1px solid var(--lora-border);
}
.base-model-dropdown-section:last-child {
border-bottom: none;
}
/* Section header */
.base-model-dropdown-header {
padding: 5px 10px;
font-size: 0.72em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-color);
opacity: 0.5;
background: var(--surface-subtle);
position: sticky;
top: 0;
z-index: 1;
}
.base-model-dropdown-header.suggested-header {
color: var(--lora-accent);
opacity: 1;
background: oklch(from var(--lora-accent) l c h / 0.08);
}
.base-model-dropdown-header.suggested-header i {
margin-right: 4px;
font-size: 0.85em;
}
/* Dropdown items */
.base-model-dropdown-item {
padding: 5px 12px;
cursor: pointer;
font-size: 0.9em;
color: var(--text-color);
transition: background 0.1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.base-model-dropdown-item:hover {
background: oklch(from var(--lora-accent) l c h / 0.1);
}
.base-model-dropdown-item.active {
background: oklch(from var(--lora-accent) l c h / 0.16);
}
.base-model-dropdown-item.selected {
font-weight: 600;
}
.base-model-dropdown-item.selected::after {
content: '✓';
float: right;
color: var(--lora-accent);
margin-left: 8px;
}
/* Empty state */
.base-model-dropdown-empty {
padding: 18px 12px;
text-align: center;
color: var(--text-color);
opacity: 0.4;
font-size: 0.88em;
}
.size-wrapper {
+38 -9
View File
@@ -229,6 +229,19 @@
gap: 10px;
}
/* Header row for params section */
.metadata-row.params-row {
flex-direction: column;
}
.metadata-row.params-row .param-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
/* Styling for parameters tags */
.params-tags {
display: flex;
@@ -272,13 +285,25 @@
margin-top: var(--space-2);
}
.metadata-row.prompt-row .param-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.metadata-row.prompt-row .param-actions {
display: flex;
align-items: center;
gap: 4px;
}
.metadata-label {
font-weight: 600;
color: var(--text-color);
opacity: 0.8;
font-size: 0.85em;
display: block;
margin-bottom: 4px;
}
.metadata-prompt-wrapper {
@@ -286,7 +311,7 @@
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
padding: 6px 30px 6px 8px;
padding: 6px 8px;
margin-top: 2px;
max-height: 80px; /* Reduced from 120px */
overflow-y: auto;
@@ -302,22 +327,26 @@
white-space: pre-wrap;
}
.copy-prompt-btn {
position: absolute;
top: 6px;
right: 6px;
.copy-prompt-btn,
.send-prompt-btn,
.send-params-btn {
background: transparent;
border: none;
color: var(--text-color);
opacity: 0.6;
cursor: pointer;
padding: 3px;
padding: 3px 6px;
border-radius: var(--border-radius-xs);
transition: var(--transition-base);
font-size: 0.9em;
}
.copy-prompt-btn:hover {
.copy-prompt-btn:hover,
.send-prompt-btn:hover,
.send-params-btn:hover {
opacity: 1;
color: var(--lora-accent);
background: var(--lora-surface);
}
/* Scrollbar styling for metadata panel */
+63 -1
View File
@@ -821,4 +821,66 @@
[data-theme="dark"] .batch-preview-item {
background: var(--lora-surface);
}
}
.hf-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 8px;
background: oklch(0.55 0.12 250 / 0.15);
color: oklch(0.7 0.12 250);
font-size: 0.75em;
font-weight: 600;
margin-left: 4px;
}
/* Checkbox inside HF batch preview items */
.batch-preview-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--lora-accent);
flex-shrink: 0;
padding: 0;
border: none;
margin: 0;
}
/* Select All toolbar in batch preview */
.batch-preview-select-all {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
background: var(--lora-surface);
cursor: pointer;
position: sticky;
top: 0;
z-index: 1;
}
.batch-preview-select-all input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--lora-accent);
flex-shrink: 0;
padding: 0;
border: none;
margin: 0;
}
.batch-preview-select-all label {
cursor: pointer;
font-size: 0.9em;
color: var(--text-color);
font-weight: 500;
margin: 0;
user-select: none;
}
[data-theme="dark"] .batch-preview-select-all {
background: var(--lora-surface);
}
+168
View File
@@ -264,6 +264,174 @@
box-shadow: 0 0 0 2px oklch(var(--lora-accent) / 0.15);
}
/* Disabled sort dropdown — used when VLM custom filter is active */
.control-group select:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--bg-color);
border-color: var(--border-color);
box-shadow: none;
transform: none;
}
.control-group select:disabled:hover {
border-color: var(--border-color);
background-color: var(--bg-color);
transform: none;
box-shadow: none;
}
/* === Sort dropdown decoupled trigger width ===========================
The native <select> sizes its trigger to the widest <option>, wasting
horizontal space when a short option is selected. This custom trigger
sizes to the currently selected text only; the dropdown menu sizes to
its content independently. The native <select> is kept in the DOM
(visually hidden) so existing JS that reads/writes `.value` / `.disabled`
and dynamically adds/removes <option>s keeps working. */
.sort-dropdown-group {
position: relative;
display: flex;
}
.sort-trigger {
display: flex;
align-items: center;
gap: 6px;
min-width: 100px;
max-width: 240px;
padding: 4px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background: var(--card-bg);
color: var(--text-color);
font-size: 0.85em;
cursor: pointer;
transition: var(--transition-base);
box-shadow: var(--shadow-xs);
}
.sort-trigger:hover,
.sort-trigger:focus-visible {
border-color: var(--lora-accent);
background: var(--bg-color);
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
outline: none;
}
.sort-trigger:active {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
.sort-trigger__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sort-trigger__caret {
opacity: 0.8;
transition: transform var(--transition-base);
flex-shrink: 0;
}
.sort-dropdown-group.active .sort-trigger__caret {
transform: rotate(180deg);
}
.sort-dropdown-group.active .sort-trigger {
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px color-mix(in oklch, var(--lora-accent) 15%, transparent);
}
/* Disabled state — mirrors the native :disabled look (used when VLM is active) */
.sort-dropdown-group.is-disabled .sort-trigger {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
background: var(--bg-color);
border-color: var(--border-color);
box-shadow: none;
transform: none;
}
/* Dropdown menu sizes to its content, independent of trigger width.
Inherits base .dropdown-menu styling; capped for very long i18n text. */
.sort-dropdown-menu {
min-width: max-content;
max-width: 320px;
width: max-content;
}
/* Optgroup label rendered as a section header */
.sort-dropdown-group .sort-optgroup-label {
padding: 8px 12px 4px;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
cursor: default;
user-select: none;
}
.sort-dropdown-group .sort-optgroup-label:first-child {
padding-top: 4px;
}
/* Option items */
.sort-dropdown-group .sort-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
color: var(--text-color);
cursor: pointer;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.sort-dropdown-group .sort-option::before {
content: '';
width: 14px;
flex-shrink: 0;
text-align: center;
font-weight: 700;
}
.sort-dropdown-group .sort-option:hover {
background-color: color-mix(in oklch, var(--lora-accent) 10%, transparent);
}
.sort-dropdown-group .sort-option.is-selected {
color: var(--lora-accent);
font-weight: 600;
}
.sort-dropdown-group .sort-option.is-selected::before {
content: '\2713';
color: var(--lora-accent);
}
/* Visually hidden native <select> kept in the DOM for programmatic access.
High-specificity selector overrides .control-group select { min-width: 100px }. */
.control-group .sort-select-native {
position: absolute;
width: 1px;
height: 1px;
min-width: 0;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
opacity: 0;
pointer-events: none;
}
/* Ensure hidden class works properly */
.hidden {
display: none !important;
+2
View File
@@ -59,3 +59,5 @@
.initialization-notice .loading-spinner {
margin-bottom: var(--space-2);
}
/* ---------- reused from shared styles ---------- */
+6
View File
@@ -190,6 +190,12 @@ export const DOWNLOAD_ENDPOINTS = {
exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images
};
// Hugging Face API endpoints
export const HF_ENDPOINTS = {
repoFiles: '/api/lm/hf-repo-files',
download: '/api/lm/download-hf-model',
};
// WebSocket endpoints
export const WS_ENDPOINTS = {
fetchProgress: '/ws/fetch-progress'
+80 -3
View File
@@ -1,12 +1,13 @@
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
import { getStorageItem, getSessionItem, removeSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
import {
getCompleteApiConfig,
getCurrentModelType,
isValidModelType,
DOWNLOAD_ENDPOINTS,
HF_ENDPOINTS,
WS_ENDPOINTS
} from './apiConfig.js';
import { resetAndReload } from './modelApiFactory.js';
@@ -115,7 +116,10 @@ export class BaseModelApiClient {
const pageState = this.getPageState();
try {
state.loadingManager.showSimpleLoading(`Loading more ${this.apiConfig.config.displayName}s...`);
// Use grid-scoped loading instead of full-page overlay
if (state.virtualScroller?.showGridLoading) {
state.virtualScroller.showGridLoading();
}
pageState.isLoading = true;
if (resetPage) {
@@ -154,7 +158,14 @@ export class BaseModelApiClient {
throw error;
} finally {
pageState.isLoading = false;
state.loadingManager.hide();
// Wait for the next rAF so refreshWithData's scheduleRender has
// completed rendering new cards before hiding the grid loading overlay.
// This eliminates the ~6.7ms blank-frame gap that caused the flicker.
if (state.virtualScroller?.hideGridLoading) {
requestAnimationFrame(() => {
state.virtualScroller.hideGridLoading();
});
}
}
}
@@ -1233,6 +1244,48 @@ export class BaseModelApiClient {
}
}
async fetchHfRepoFiles(repo, revision = 'main') {
try {
const params = new URLSearchParams({ repo, revision });
const response = await fetch(`${HF_ENDPOINTS.repoFiles}?${params}`);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to fetch HF repo files');
}
return await response.json();
} catch (error) {
console.error('Error fetching HF repo files:', error);
throw error;
}
}
async downloadHfModel({ repo, filename, revision, modelRoot, relativePath, useDefaultPaths, download_id }) {
try {
const response = await fetch(HF_ENDPOINTS.download, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
repo,
filename,
revision: revision || 'main',
model_root: modelRoot,
relative_path: relativePath || '',
use_default_paths: useDefaultPaths || false,
...(download_id ? { download_id } : {}),
})
});
if (!response.ok) {
throw new Error(await response.text());
}
return await response.json();
} catch (error) {
console.error('Error downloading HF model:', error);
throw error;
}
}
_buildQueryParams(baseParams, pageState) {
const params = new URLSearchParams(baseParams);
const isExcludedView = pageState.viewMode === 'excluded';
@@ -1271,6 +1324,12 @@ export class BaseModelApiClient {
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
// Pass group-by-model mode to backend (skip when showing all versions of a specific model)
const vlmModelId = getSessionItem('vlm_model_id');
if (state.global.settings.group_by_model && !vlmModelId) {
params.append('group_by_model', 'true');
}
if (!isExcludedView && pageState.filters) {
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
@@ -1352,6 +1411,24 @@ export class BaseModelApiClient {
}
_addModelSpecificParams(params, pageState) {
// Check for View Local Versions filter (takes priority over recipe filters)
const vlmModelId = getSessionItem('vlm_model_id');
const vlmPageType = getSessionItem('vlm_page_type');
if (vlmModelId && vlmPageType === this.modelType) {
params.append('civitai_model_id', vlmModelId);
const vlmBaseModel = getSessionItem('vlm_base_model');
if (vlmBaseModel) {
params.append('base_model', vlmBaseModel);
}
return;
} else if (vlmModelId && vlmPageType !== this.modelType) {
// Stale VLM data from a different page type — clean up
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
}
if (this.modelType === 'loras') {
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
+7
View File
@@ -9,6 +9,13 @@ export class LoraApiClient extends BaseModelApiClient {
* Add LoRA-specific parameters to query
*/
_addModelSpecificParams(params, pageState) {
// Let parent handle View Local Versions filter first
super._addModelSpecificParams(params, pageState);
// If VLM filter was applied, skip recipe-specific filters
if (params.has('civitai_model_id')) {
return;
}
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
@@ -274,6 +274,9 @@ export class BulkContextMenu extends BaseContextMenu {
case 'resume-metadata-refresh':
bulkManager.setSkipMetadataRefresh(false);
break;
case 'enrich-hf-agent-bulk':
this.enrichBulkWithAgent();
break;
case 'delete-all':
bulkManager.showBulkDeleteModal();
break;
@@ -363,4 +366,66 @@ export class BulkContextMenu extends BaseContextMenu {
console.error('Bulk download example images failed:', error);
}
}
/**
* Enrich metadata for selected models via LLM agent skill.
*/
async enrichBulkWithAgent() {
if (state.selectedModels.size === 0) {
return;
}
const { agentManager } = await import('../../managers/AgentManager.js');
// Check if LLM is configured
const configured = await agentManager.isLlmConfigured();
if (!configured) {
showToast('toast.agent.llmNotConfigured', {}, 'warning');
return;
}
const modelPaths = [...state.selectedModels];
// Connect WebSocket for progress
agentManager.connect();
// Set up one-time completion handler
const onComplete = (data) => {
const idx = agentManager.completeCallbacks.indexOf(onComplete);
if (idx >= 0) agentManager.completeCallbacks.splice(idx, 1);
if (data.status === 'completed') {
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'
);
try {
await agentManager.executeSkill('enrich_hf_metadata', modelPaths);
} catch (error) {
showToast(
'toast.agent.enrichFailed',
{ error: error.message },
'error'
);
}
}
}
@@ -24,6 +24,14 @@ export class GlobalContextMenu extends BaseContextMenu {
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
// Update check indicator for group-by-model
if (groupByModelCheck) {
const isEnabled = !!state.global.settings.group_by_model;
groupByModelCheck.style.display = isEnabled ? 'block' : 'none';
}
if (isRecipesPage) {
modelUpdateItem?.classList.add('hidden');
@@ -31,6 +39,7 @@ export class GlobalContextMenu extends BaseContextMenu {
downloadExamplesItem?.classList.add('hidden');
cleanupExamplesItem?.classList.add('hidden');
excludedModelsItem?.classList.add('hidden');
groupByModelItem?.classList.add('hidden');
repairRecipesItem?.classList.remove('hidden');
} else {
modelUpdateItem?.classList.remove('hidden');
@@ -38,6 +47,7 @@ export class GlobalContextMenu extends BaseContextMenu {
downloadExamplesItem?.classList.remove('hidden');
cleanupExamplesItem?.classList.remove('hidden');
excludedModelsItem?.classList.remove('hidden');
groupByModelItem?.classList.remove('hidden');
repairRecipesItem?.classList.add('hidden');
}
@@ -74,6 +84,9 @@ export class GlobalContextMenu extends BaseContextMenu {
case 'manage-excluded-models':
this.manageExcludedModels();
break;
case 'toggle-group-by-model':
this.toggleGroupByModel();
break;
default:
console.warn(`Unhandled global context menu action: ${action}`);
break;
@@ -86,6 +99,30 @@ export class GlobalContextMenu extends BaseContextMenu {
});
}
toggleGroupByModel() {
const sm = window.settingsManager;
if (!sm) {
console.error('settingsManager not available on window');
return;
}
const newValue = !state.global.settings.group_by_model;
state.global.settings.group_by_model = newValue;
// Save/restore sort preference when toggling group_by_model
if (window.pageControls?.onGroupByModelToggled) {
window.pageControls.onGroupByModelToggled(newValue);
}
sm.saveSetting('group_by_model', newValue).catch((error) => {
console.error('Failed to save group_by_model setting:', error);
// Revert state on failure
state.global.settings.group_by_model = !newValue;
});
sm.applyFrontendSettings();
sm.reloadContent();
}
async downloadExampleImages(menuItem) {
const downloadPath = state?.global?.settings?.example_images_path;
if (!downloadPath) {
@@ -1,7 +1,7 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js';
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax, showToast } from '../../utils/uiHelpers.js';
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
import { moveManager } from '../../managers/MoveManager.js';
@@ -63,6 +63,9 @@ export class LoraContextMenu extends BaseContextMenu {
case 'refresh-metadata':
getModelApiClient().refreshSingleModelMetadata(this.currentCard.dataset.filepath);
break;
case 'enrich-hf-agent':
this.enrichWithAgent(this.currentCard.dataset.filepath);
break;
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath);
break;
@@ -72,6 +75,46 @@ 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 onComplete = (data) => {
const idx = agentManager.completeCallbacks.indexOf(onComplete);
if (idx >= 0) agentManager.completeCallbacks.splice(idx, 1);
if (data.status === 'completed') {
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');
try {
await agentManager.executeSkill('enrich_hf_metadata', [filePath]);
} catch (error) {
showToast('toast.agent.enrichFailed', { error: error.message }, 'error');
}
}
sendLoraToWorkflow(replaceMode) {
const card = this.currentCard;
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
+1 -2
View File
@@ -338,7 +338,6 @@ export class HeaderManager {
const headerSearch = document.getElementById('headerSearch');
const searchInput = headerSearch?.querySelector('#searchInput');
const searchButtons = headerSearch?.querySelectorAll('button');
const placeholderKey = 'header.search.placeholders.' + this.currentPage;
if (this.currentPage === 'statistics' && headerSearch) {
headerSearch.classList.add('disabled');
@@ -353,7 +352,7 @@ export class HeaderManager {
if (searchInput) {
searchInput.disabled = false;
// Use i18nHelpers to update placeholder
updateElementAttribute(searchInput, 'placeholder', placeholderKey, {}, '');
updateElementAttribute(searchInput, 'placeholder', 'header.search.placeholder', {}, '');
}
searchButtons?.forEach(btn => btn.disabled = false);
}
+61 -10
View File
@@ -1,5 +1,5 @@
// Recipe Modal Component
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
@@ -40,6 +40,16 @@ const GEN_PARAM_NORMALIZATION = {
'Denoising strength': 'denoising_strength',
};
const PARAM_DISPLAY_NAMES = {
steps: 'Steps',
sampler: 'Sampler',
cfg_scale: 'CFG',
seed: 'Seed',
size: 'Size',
clip_skip: 'Clip Skip',
denoising_strength: 'Denoising Strength',
};
class RecipeModal {
constructor() {
this.promptEditorState = {};
@@ -588,10 +598,11 @@ class RecipeModal {
for (const [key, value] of Object.entries(sanitizedGenParams)) {
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
const displayName = PARAM_DISPLAY_NAMES[key] || key;
const paramTag = document.createElement('div');
paramTag.className = 'param-tag';
paramTag.innerHTML = `
<span class="param-name">${key}:</span>
<span class="param-name">${displayName}:</span>
<span class="param-value">${value}</span>
`;
otherParamsElement.appendChild(paramTag);
@@ -1200,6 +1211,53 @@ class RecipeModal {
this.sendRecipeToWorkflow();
});
}
// Send prompt to workflow buttons
const sendPromptBtn = document.getElementById('sendPromptBtn');
const sendNegativePromptBtn = document.getElementById('sendNegativePromptBtn');
if (sendPromptBtn) {
sendPromptBtn.addEventListener('click', () => {
let promptText = this.currentRecipe?.gen_params?.prompt || '';
if (this.shouldStripLoraOnCopy()) {
promptText = RecipeModal.stripLoraTags(promptText);
}
if (!promptText.trim()) {
showToast('toast.recipes.noPromptToSend', {}, 'warning');
return;
}
sendPromptToWorkflow(promptText);
});
}
if (sendNegativePromptBtn) {
sendNegativePromptBtn.addEventListener('click', () => {
let negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
if (this.shouldStripLoraOnCopy()) {
negativePromptText = RecipeModal.stripLoraTags(negativePromptText);
}
if (!negativePromptText.trim()) {
showToast('toast.recipes.noPromptToSend', {}, 'warning');
return;
}
sendPromptToWorkflow(negativePromptText, {
actionTypeText: 'Negative Prompt',
});
});
}
// Send params to workflow button
const sendParamsBtn = document.getElementById('sendParamsBtn');
if (sendParamsBtn) {
sendParamsBtn.addEventListener('click', () => {
const genParams = this.currentRecipe?.gen_params || {};
if (!genParams || Object.keys(genParams).length === 0) {
showToast('No generation parameters available', {}, 'warning');
return;
}
sendGenParamsToWorkflow(genParams);
});
}
}
/**
@@ -1208,14 +1266,7 @@ class RecipeModal {
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
*/
static stripLoraTags(text) {
return text
.replace(/<lora:[^>]*>/gi, '')
.replace(/&lt;lora:[^&]*&gt;/gi, '')
.replace(/,(\s*,)+/g, ',')
.replace(/^,\s*/, '')
.replace(/,\s*$/, '')
.replace(/\s{2,}/g, ' ')
.trim();
return stripLoraTags(text);
}
shouldStripLoraOnCopy() {
@@ -95,6 +95,23 @@ export class CheckpointsControls extends PageControls {
* Clear checkpoint custom filter and reload
*/
async clearCustomFilter() {
// Check for View Local Versions filter first
const vlmModelId = getSessionItem('vlm_model_id');
if (vlmModelId) {
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
this._restoreSortAfterVlm();
// Hide the indicator
const indicator = document.getElementById('customFilterIndicator');
if (indicator) {
indicator.classList.add('hidden');
}
await resetAndReload();
return;
}
removeSessionItem('recipe_to_checkpoint_filterHash');
removeSessionItem('recipe_to_checkpoint_filterHashes');
removeSessionItem('filterCheckpointRecipeName');
@@ -106,14 +123,4 @@ export class CheckpointsControls extends PageControls {
await resetAndReload();
}
/**
* Helper to truncate text with ellipsis
* @param {string} text
* @param {number} maxLength
* @returns {string}
*/
_truncateText(text, maxLength) {
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
}
}
+16 -10
View File
@@ -112,6 +112,22 @@ export class LorasControls extends PageControls {
* Clear the custom filter and reload the page
*/
async clearCustomFilter() {
// Check for View Local Versions filter first (handles VLM and reloads)
const vlmModelId = getSessionItem('vlm_model_id');
if (vlmModelId) {
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
this._restoreSortAfterVlm();
const indicator = document.getElementById('customFilterIndicator');
if (indicator) {
indicator.classList.add('hidden');
}
await resetAndReload();
return;
}
console.log("Clearing custom filter...");
// Remove filter parameters from session storage
removeSessionItem('recipe_to_lora_filterLoraHash');
@@ -134,16 +150,6 @@ export class LorasControls extends PageControls {
await resetAndReload();
}
/**
* Helper to truncate text with ellipsis
* @param {string} text - Text to truncate
* @param {number} maxLength - Maximum length before truncating
* @returns {string} - Truncated text
*/
_truncateText(text, maxLength) {
return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text;
}
/**
* Initialize the alphabet bar component
*/
+222 -4
View File
@@ -1,9 +1,10 @@
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js';
import { initSortDropdown } from './SortDropdown.js';
/**
* PageControls class - Unified control management for model pages
@@ -106,6 +107,7 @@ export class PageControls {
// Sort select handler
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
initSortDropdown(sortSelect);
sortSelect.value = this.pageState.sortBy;
sortSelect.addEventListener('change', async (e) => {
this.pageState.sortBy = e.target.value;
@@ -129,6 +131,9 @@ export class PageControls {
clearFilterBtn.addEventListener('click', () => this.clearCustomFilter());
}
// Check for View Local Versions filter
this.checkVlmFilter();
// Page-specific event listeners
this.initPageSpecificListeners();
}
@@ -311,7 +316,12 @@ export class PageControls {
* Load sort preference from storage
*/
loadSortPreference() {
const savedSort = getStorageItem(`${this.pageType}_sort`);
// Use separate keys for grouped vs non-grouped sort so each mode
// remembers its own preference independently
const key = state.global.settings.group_by_model
? `${this.pageType}_sort_grouped`
: `${this.pageType}_sort`;
const savedSort = getStorageItem(key);
if (savedSort) {
// Handle legacy format conversion
const convertedSort = this.convertLegacySortFormat(savedSort);
@@ -355,7 +365,11 @@ export class PageControls {
};
return;
}
setStorageItem(`${this.pageType}_sort`, sortValue);
// Separate storage for grouped vs non-grouped sort
const key = state.global.settings.group_by_model
? `${this.pageType}_sort_grouped`
: `${this.pageType}_sort`;
setStorageItem(key, sortValue);
}
/**
@@ -459,15 +473,211 @@ export class PageControls {
this.api.toggleBulkMode();
}
/**
* Clear custom filter
*/
/**
* Dynamically add the VLM sort option (version_id:desc) to the sort dropdown.
* It is not a permanent option only present while VLM is active.
*/
_addVlmSortOption() {
const sortSelect = document.getElementById('sortSelect');
if (!sortSelect) return;
// Only add if not already present
if (sortSelect.querySelector('option[value="version_id:desc"]')) return;
const opt = document.createElement('option');
opt.value = 'version_id:desc';
opt.textContent = this._t('loras.controls.sort.versionIdDesc', 'Newest version first');
sortSelect.appendChild(opt);
}
/**
* Remove the VLM sort option from the sort dropdown.
*/
_removeVlmSortOption() {
const sortSelect = document.getElementById('sortSelect');
if (!sortSelect) return;
const opt = sortSelect.querySelector('option[value="version_id:desc"]');
if (opt) opt.remove();
}
/**
* Look up a translation key via the global i18n helper, falling back to
* a plain-text default when the key is missing or i18n is unavailable.
*/
_t(key, fallback) {
if (typeof window.i18n?.t === 'function') {
return window.i18n.t(key, { defaultValue: fallback });
}
return fallback;
}
/**
* Restore the sort dropdown state after VLM is cleared.
* Shared by PageControls.clearCustomFilter() and subclass overrides.
*/
_restoreSortAfterVlm() {
const prevSort = getSessionItem('vlm_prev_sort');
removeSessionItem('vlm_prev_sort');
const restoredSort = prevSort || 'name:asc';
this.pageState.sortBy = restoredSort;
this.saveSortPreference(restoredSort);
this._removeVlmSortOption();
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = restoredSort;
sortSelect.disabled = false;
}
}
/**
* Trigger View Local Versions without page reload
* Sets sessionStorage and reloads data via the API.
*/
triggerVlmView(modelId, modelName, baseModel, pageType) {
const targetPageType = pageType || this.pageType;
setSessionItem('vlm_model_id', String(modelId));
setSessionItem('vlm_model_name', modelName || String(modelId));
setSessionItem('vlm_page_type', targetPageType);
if (baseModel) {
setSessionItem('vlm_base_model', baseModel);
} else {
removeSessionItem('vlm_base_model');
}
// Save current sort preference so it can be restored when VLM is cleared
setSessionItem('vlm_prev_sort', this.pageState.sortBy);
// Inject the temporary sort option and force version_id:desc
this._addVlmSortOption();
this.pageState.sortBy = 'version_id:desc';
this.saveSortPreference('version_id:desc');
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = 'version_id:desc';
sortSelect.disabled = true;
}
// Reload data via API (no page reload)
this.resetAndReload(true).then(() => {
// Show the VLM indicator after data loads
this.checkVlmFilter();
});
}
/**
* Called when group_by_model is toggled.
* Swaps between {pageType}_sort (non-group) and {pageType}_sort_grouped,
* so each mode remembers its own sort preference independently.
*/
onGroupByModelToggled(isEnabled) {
const groupedKey = `${this.pageType}_sort_grouped`;
if (isEnabled) {
// Entering group mode: restore last-used grouped sort, if any
const savedGroupedSort = getStorageItem(groupedKey);
if (savedGroupedSort) {
this.pageState.sortBy = savedGroupedSort;
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = savedGroupedSort;
}
}
} else {
// Leaving group mode: persist current sort for next time, restore non-group sort
setStorageItem(groupedKey, this.pageState.sortBy);
const savedNormalSort = getStorageItem(`${this.pageType}_sort`);
if (savedNormalSort) {
this.pageState.sortBy = savedNormalSort;
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = savedNormalSort;
}
}
}
}
/**
* Check for View Local Versions filter in sessionStorage (page-type-scoped)
*/
checkVlmFilter() {
const vlmModelId = getSessionItem('vlm_model_id');
const vlmPageType = getSessionItem('vlm_page_type');
const sortSelect = document.getElementById('sortSelect');
// Only show VLM indicator when it belongs to the current page type
if (vlmModelId && vlmPageType !== this.pageType) {
// Stale VLM data from a different page — clean up
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
removeSessionItem('vlm_prev_sort');
this._removeVlmSortOption();
if (sortSelect) sortSelect.disabled = false;
return;
}
const vlmModelName = getSessionItem('vlm_model_name');
const vlmBaseModel = getSessionItem('vlm_base_model');
if (vlmModelId && vlmModelName) {
// VLM is active — inject sort option, disable dropdown, show indicator
this._addVlmSortOption();
if (sortSelect) {
sortSelect.value = 'version_id:desc';
sortSelect.disabled = true;
}
const indicator = document.getElementById('customFilterIndicator');
const filterText = indicator?.querySelector('.customFilterText');
if (indicator && filterText) {
indicator.classList.remove('hidden');
const prefix = vlmBaseModel
? 'Showing same-base versions from'
: 'Showing all versions from';
const displayText = `${prefix}: ${vlmModelName}`;
filterText.textContent = this._truncateText(displayText, 40);
filterText.setAttribute('title', displayText);
}
} else {
// No VLM — ensure sort option is removed and dropdown is enabled
this._removeVlmSortOption();
if (sortSelect) sortSelect.disabled = false;
}
}
/**
* Clear custom filter
*/
async clearCustomFilter() {
// Check for View Local Versions filter first
const vlmModelId = getSessionItem('vlm_model_id');
if (vlmModelId) {
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
this._restoreSortAfterVlm();
// Hide the indicator
const indicator = document.getElementById('customFilterIndicator');
if (indicator) {
indicator.classList.add('hidden');
}
// Reload data via API (no page reload)
await this.resetAndReload(true);
return;
}
// Otherwise delegate to subclass for recipe filters
if (!this.api) {
console.error('API methods not registered');
return;
}
try {
await this.api.clearCustomFilter();
} catch (error) {
@@ -475,6 +685,14 @@ export class PageControls {
showToast('toast.controls.clearFilterFailed', { message: error.message }, 'error');
}
}
/**
* Truncate text with ellipsis
*/
_truncateText(text, maxLength) {
if (!text) return '';
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
}
/**
* Initialize the favorites filter button state
@@ -0,0 +1,294 @@
// SortDropdown.js — Decoupled sort trigger.
//
// The native <select> sizes its trigger to the widest <option>, so long
// options (e.g. "Fewest versions first") or long i18n translations force the
// control to be far wider than the selected text needs. This module wraps the
// existing <select> with a custom trigger + menu that mirror its state, so the
// trigger sizes to the selected text while the menu sizes to its content.
//
// The native <select> stays in the DOM (visually hidden) so existing code that
// reads/writes `.value` / `.disabled` and dynamically adds/removes <option>s
// (e.g. the VLM temporary option) keeps working unchanged. The `value` and
// `disabled` setters are overridden on the instance to keep the trigger label
// and disabled styling in sync with programmatic changes.
//
// Keyboard navigation (arrows, Home/End, type-to-select) mirrors native
// <select> behavior so the control remains fully accessible.
const SORT_GROUP_SELECTOR = '.sort-dropdown-group';
const ACTIVE_GROUP_SELECTOR = '.sort-dropdown-group.active, .dropdown-group.active';
/**
* Initialize a decoupled sort dropdown around a native <select>.
* Idempotent: safe to call more than once on the same element.
* @param {HTMLSelectElement|null} select
* @returns {void}
*/
export function initSortDropdown(select) {
if (!select) return;
const group = select.closest(SORT_GROUP_SELECTOR);
if (!group || group.dataset.sortReady === '1') return;
const trigger = group.querySelector('.sort-trigger');
const menu = group.querySelector('.sort-dropdown-menu');
const label = group.querySelector('.sort-trigger__label');
if (!trigger || !menu || !label) return;
const getOptions = () => menu.querySelectorAll('.sort-option');
const buildItem = (opt) => {
const item = document.createElement('div');
item.className = 'sort-option';
item.setAttribute('role', 'option');
item.tabIndex = -1;
item.dataset.value = opt.value;
item.textContent = opt.textContent;
item.addEventListener('click', (event) => {
event.stopPropagation();
if (select.disabled) return;
choose(opt.value);
close();
});
return item;
};
const buildMenu = () => {
menu.innerHTML = '';
const fragment = document.createDocumentFragment();
for (const child of Array.from(select.children)) {
if (child.tagName === 'OPTGROUP') {
const header = document.createElement('div');
header.className = 'sort-optgroup-label';
header.textContent = child.label || '';
fragment.appendChild(header);
for (const opt of Array.from(child.children)) {
fragment.appendChild(buildItem(opt));
}
} else if (child.tagName === 'OPTION') {
fragment.appendChild(buildItem(child));
}
}
menu.appendChild(fragment);
syncSelected();
};
const syncSelected = () => {
const value = select.value;
let labelText = '';
let matched = false;
getOptions().forEach((el) => {
const selected = el.dataset.value === value;
el.classList.toggle('is-selected', selected);
el.setAttribute('aria-selected', selected ? 'true' : 'false');
if (selected) {
labelText = el.textContent;
matched = true;
}
});
if (!matched) {
const opt = select.querySelector(`option[value="${cssEscape(value)}"]`);
labelText = opt
? opt.textContent
: (select.options[select.selectedIndex]?.textContent ?? '');
}
label.textContent = labelText;
};
const choose = (value) => {
if (select.value === value) return;
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true }));
};
const open = () => {
document.querySelectorAll(ACTIVE_GROUP_SELECTOR).forEach((g) => {
if (g !== group) g.classList.remove('active');
});
group.classList.add('active');
trigger.setAttribute('aria-expanded', 'true');
// Focus the currently selected option (or the first option) so
// keyboard navigation starts from a sensible position.
requestAnimationFrame(() => {
const selected = menu.querySelector('.sort-option.is-selected');
(selected || getOptions()[0])?.focus();
});
};
const close = () => {
group.classList.remove('active');
trigger.setAttribute('aria-expanded', 'false');
};
const toggle = () => {
if (group.classList.contains('active')) close();
else open();
};
// ---- keyboard navigation ----
// Type-to-select buffer: accumulate characters and reset after a pause.
// Shared between trigger and menu keydown handlers.
let typeBuffer = '';
let typeTimer = null;
const focusOptionByText = (prefix) => {
const options = getOptions();
const lower = prefix.toLowerCase();
for (let i = 0; i < options.length; i++) {
if (options[i].textContent.toLowerCase().startsWith(lower)) {
options[i].focus();
return;
}
}
};
const moveFocus = (options, direction) => {
const focused = menu.querySelector('.sort-option:focus');
let idx = focused ? Array.from(options).indexOf(focused) : -1;
idx = Math.max(0, Math.min(options.length - 1, idx + direction));
options[idx]?.focus();
};
const handleTypeToSelect = (event) => {
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return false;
event.preventDefault();
clearTimeout(typeTimer);
typeBuffer += event.key;
focusOptionByText(typeBuffer);
typeTimer = setTimeout(() => { typeBuffer = ''; }, 800);
return true;
};
trigger.addEventListener('click', (event) => {
event.stopPropagation();
if (select.disabled) return;
toggle();
});
trigger.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
close();
} else if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
event.preventDefault();
if (!select.disabled) toggle();
} else if (!group.classList.contains('active')) {
// Type-to-select on closed dropdown: open and highlight match
if (handleTypeToSelect(event)) {
open();
}
}
});
menu.addEventListener('keydown', (event) => {
const options = getOptions();
if (options.length === 0) return;
switch (event.key) {
case 'Escape':
event.preventDefault();
close();
trigger.focus();
return;
case 'ArrowDown':
event.preventDefault();
moveFocus(options, 1);
return;
case 'ArrowUp':
event.preventDefault();
moveFocus(options, -1);
return;
case 'Home':
event.preventDefault();
options[0]?.focus();
return;
case 'End':
event.preventDefault();
options[options.length - 1]?.focus();
return;
case 'Enter':
case ' ':
event.preventDefault();
if (select.disabled) return;
const focused = menu.querySelector('.sort-option:focus');
if (focused) {
choose(focused.dataset.value);
close();
trigger.focus();
}
return;
}
handleTypeToSelect(event);
});
// Close dropdown when clicking outside
document.addEventListener('click', (event) => {
if (!group.contains(event.target)) {
const wasOpen = group.classList.contains('active');
close();
// Only return focus to the trigger when the dropdown was actually
// open — avoids forcing scrollIntoView on every page click (which
// causes the scroll container to jump when clicking a model card).
if (wasOpen) trigger.focus();
}
});
// ---- property overrides ----
// Override `value` and `disabled` on this instance so programmatic
// changes (loadSortPreference, VLM toggle, excluded-view sync, ...) keep
// the trigger label and disabled styling in sync without touching callers.
const proto = Object.getPrototypeOf(select);
const valueDescriptor =
Object.getOwnPropertyDescriptor(proto, 'value') ||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
const disabledDescriptor =
Object.getOwnPropertyDescriptor(proto, 'disabled') ||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'disabled');
if (valueDescriptor) {
Object.defineProperty(select, 'value', {
get() { return valueDescriptor.get.call(this); },
set(v) {
valueDescriptor.set.call(this, v);
syncSelected();
},
configurable: true,
});
}
if (disabledDescriptor) {
Object.defineProperty(select, 'disabled', {
get() { return disabledDescriptor.get.call(this); },
set(v) {
disabledDescriptor.set.call(this, v);
group.classList.toggle('is-disabled', Boolean(v));
trigger.disabled = Boolean(v);
if (v) close();
},
configurable: true,
});
}
// Rebuild the menu when <option>s change (VLM adds/removes a temporary
// option at runtime).
const observer = new MutationObserver(() => buildMenu());
observer.observe(select, { childList: true });
buildMenu();
group.dataset.sortReady = '1';
}
function cssEscape(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value);
}
// Fallback for environments without CSS.escape
return String(value).replace(/[!"#$%&'()*+,./:;<=>?@[\]^`{|}~\\ -]/g, '\\$&');
}
+53 -7
View File
@@ -1,4 +1,4 @@
import { showToast, openCivitai, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js';
@@ -66,6 +66,8 @@ function handleModelCardEvent_internal(event, modelType) {
event.stopPropagation();
if (card.dataset.from_civitai === 'true') {
openCivitai(card.dataset.filepath);
} else if (card.dataset.hf_url) {
openHuggingFace(card.dataset.hf_url);
}
return true; // Stop propagation
}
@@ -100,6 +102,12 @@ function handleModelCardEvent_internal(event, modelType) {
return true; // Stop propagation
}
if (event.target.closest('.version-count-link')) {
event.stopPropagation();
handleViewLocalVersionsFromCard(card, modelType);
return true;
}
// If no specific element was clicked, handle the card click (show modal or toggle selection)
handleCardClick(card, modelType);
return false; // Continue with other handlers (e.g., bulk selection)
@@ -265,6 +273,22 @@ async function handleExampleImagesAccess(card, modelType) {
}
}
function handleViewLocalVersionsFromCard(card, modelType) {
const modelId = card.dataset.modelId;
const modelName = card.dataset.name;
if (!modelId) return;
// Respect version_grouping: only filter by base model when the strategy says so
const strategy = state.global?.settings?.version_grouping;
const shouldFilterByBase = strategy === 'same_base';
const baseModel = shouldFilterByBase && card.dataset.base_model !== 'Unknown'
? card.dataset.base_model
: undefined;
// Use the no-reload VLM flow via PageControls
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
window.pageControls.triggerVlmView(modelId, modelName, baseModel, modelType);
}
}
function handleCardClick(card, modelType) {
const pageState = getCurrentPageState();
@@ -291,6 +315,7 @@ async function showModelModalFromCard(card, modelType) {
modified: card.dataset.modified,
file_size: parseInt(card.dataset.file_size || '0'),
from_civitai: card.dataset.from_civitai === 'true',
hf_url: card.dataset.hf_url || '',
base_model: card.dataset.base_model,
notes: card.dataset.notes || '',
favorite: card.dataset.favorite === 'true',
@@ -379,6 +404,7 @@ function showExampleAccessModal(card, modelType) {
modified: card.dataset.modified,
file_size: card.dataset.file_size,
from_civitai: card.dataset.from_civitai === 'true',
hf_url: card.dataset.hf_url || '',
base_model: card.dataset.base_model,
notes: card.dataset.notes,
favorite: card.dataset.favorite === 'true',
@@ -445,9 +471,14 @@ export function createModelCard(model, modelType) {
card.dataset.base_model = model.base_model || 'Unknown';
card.dataset.favorite = model.favorite ? 'true' : 'false';
card.dataset.exclude = model.exclude ? 'true' : 'false';
card.dataset.hf_url = model.hf_url || '';
const hasUpdateAvailable = Boolean(model.update_available);
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
// Store version_count for group-by-model display
if (model.version_count !== undefined) {
card.dataset.version_count = model.version_count;
}
// To only show usage_count when sorting by usage.
const pageState = getCurrentPageState();
@@ -552,7 +583,10 @@ export function createModelCard(model, modelType) {
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
const globeTitle = model.from_civitai ?
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
model.hf_url ?
translate('modelCard.actions.viewOnHuggingFace', {}, 'View on Hugging Face') :
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
const globeEnabled = model.from_civitai || !!model.hf_url;
let sendTitle;
let copyTitle;
if (modelType === MODEL_TYPES.LORA) {
@@ -577,7 +611,7 @@ export function createModelCard(model, modelType) {
</i>
<i class="fas fa-globe"
title="${globeTitle}"
${!model.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
${!globeEnabled ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
</i>
<i class="fas fa-paper-plane"
title="${sendTitle}">
@@ -659,16 +693,28 @@ export function createModelCard(model, modelType) {
const autoTags = model.auto_tags || [];
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
const hasVersionName = model.civitai?.name;
if (!hlTags.length && !hasVersionName) return '';
// When group_by_model is active and model has multiple versions,
// show clickable version count instead of version name (and hide badges)
const isGroupByModel = state.global.settings.group_by_model;
const versionCount = model.version_count;
const showVersionCount = isGroupByModel && versionCount > 1;
if (!hlTags.length && !hasVersionName && !showVersionCount) return '';
const density = state.global.settings.display_density || 'default';
const shortLabels = density === 'medium' || density === 'compact';
const badges = hlTags.map(t => {
// Don't show HIGH/LOW badges when showing version count (confusing in grouped mode)
const badges = !showVersionCount ? hlTags.map(t => {
const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
const titleAttr = shortLabels ? ` title="${t}"` : '';
return `<span class="${cls}"${titleAttr}>${label}</span>`;
}).join('');
const versionHtml = hasVersionName ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : '';
}).join('') : '';
let versionHtml = '';
if (showVersionCount) {
const countLabel = translate('modelCard.footer.versionCount', { count: versionCount }, `${versionCount} versions`);
versionHtml = `<span class="version-count-link" title="${translate('modelCard.footer.viewAllVersions', {}, 'View all local versions')}">${countLabel}</span>`;
} else if (hasVersionName) {
versionHtml = `<span class="version-name civitai-version">${model.civitai.name}</span>`;
}
return `<span class="badge-version-unit">${badges}${versionHtml}</span>`;
})()}
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
+292 -71
View File
@@ -3,9 +3,75 @@
* Handles model metadata editing functionality - General version
*/
import { BASE_MODEL_CATEGORIES } from '../../utils/constants.js';
import { BASE_MODEL_CATEGORIES, getMergedBaseModels } from '../../utils/constants.js';
import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { translate } from '../../utils/i18nHelpers.js';
// ── Filename-based base model inference ──────────────────────────────────────
// Rules are ordered by specificity — first match wins for dedup.
// Each rule checks the filename (lowercased) for a regex pattern and suggests
// the associated base model values.
const BASE_MODEL_FILENAME_RULES = [
{ pattern: /flux\.?\s*2\s*klein/i, models: ['Flux.2 Klein 9B', 'Flux.2 Klein 9B-base', 'Flux.2 Klein 4B', 'Flux.2 Klein 4B-base'] },
{ pattern: /flux\.?\s*2/i, models: ['Flux.2 D', 'Flux.2 Klein 9B', 'Flux.2 Klein 4B'] },
{ pattern: /flux\.?\s*1\s*(dev|d)\b/i, models: ['Flux.1 D'] },
{ pattern: /flux\.?\s*1\s*(schnell|s)\b/i, models: ['Flux.1 S'] },
{ pattern: /flux/i, models: ['Flux.1 D', 'Flux.1 S', 'Flux.2 D'] },
{ pattern: /sdxl/i, models: ['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper'] },
{ pattern: /sd\s*1[._-\s]?5/i, models: ['SD 1.5'] },
{ pattern: /sd\s*1[._-\s]?4/i, models: ['SD 1.4'] },
{ pattern: /sd\s*1/i, models: ['SD 1.5', 'SD 1.4', 'SD 1.5 LCM', 'SD 1.5 Hyper'] },
{ pattern: /sd\s*3[._-\s]?5/i, models: ['SD 3.5', 'SD 3.5 Medium', 'SD 3.5 Large', 'SD 3.5 Large Turbo'] },
{ pattern: /sd\s*3/i, models: ['SD 3', 'SD 3.5'] },
{ pattern: /wan\s*\.?\s*video/i, models: ['Wan Video', 'Wan Video 1.3B t2v', 'Wan Video 14B t2v', 'Wan Video 14B i2v 480p', 'Wan Video 14B i2v 720p'] },
{ pattern: /hunyuan\s*\.?\s*video/i, models: ['Hunyuan Video'] },
{ pattern: /ltxv/i, models: ['LTXV', 'LTXV2', 'LTXV 2.3'] },
{ pattern: /cogvideo/i, models: ['CogVideoX'] },
{ pattern: /pony/i, models: ['Pony', 'Pony V7'] },
{ pattern: /illustrious/i, models: ['Illustrious'] },
{ pattern: /noobai/i, models: ['NoobAI'] },
{ pattern: /pixart/i, models: ['PixArt a', 'PixArt E'] },
{ pattern: /aura\s*\.?\s*flow/i, models: ['AuraFlow'] },
{ pattern: /kolors/i, models: ['Kolors'] },
{ pattern: /hunyuan\s*1/i, models: ['Hunyuan 1'] },
{ pattern: /lumina/i, models: ['Lumina'] },
{ pattern: /hidream/i, models: ['HiDream'] },
{ pattern: /qwen/i, models: ['Qwen'] },
{ pattern: /chroma/i, models: ['Chroma'] },
{ pattern: /anima/i, models: ['Anima'] },
{ pattern: /sd\s*2[._-\s]?[01]/i, models: ['SD 2.0', 'SD 2.1'] },
{ pattern: /mochi/i, models: ['Mochi'] },
{ pattern: /svd/i, models: ['SVD'] },
{ pattern: /zimage/i, models: ['ZImageTurbo', 'ZImageBase'] },
{ pattern: /nucleus/i, models: ['Nucleus'] },
{ pattern: /krea/i, models: ['Flux.1 Krea', 'Krea 2'] },
{ pattern: /ernie/i, models: ['Ernie', 'Ernie Turbo'] },
];
/**
* Infer likely base model(s) from a filename + model name string.
* Returns a deduplicated array in match-priority order.
* @param {string} filename
* @returns {string[]}
*/
function inferBaseModelsFromFilename(filename) {
if (!filename || typeof filename !== 'string') return [];
const seen = new Set();
const results = [];
for (const rule of BASE_MODEL_FILENAME_RULES) {
if (rule.pattern.test(filename)) {
for (const model of rule.models) {
if (!seen.has(model)) {
seen.add(model);
results.push(model);
}
}
}
}
return results;
}
/**
* Resolve the active file path for the currently open model modal.
@@ -226,7 +292,9 @@ export function setupModelNameEditing(filePath) {
}
/**
* Set up base model editing functionality
* Set up base model editing functionality with searchable dropdown
* Shows filename-inferred suggestions at the top, supports keyboard navigation,
* and allows typing custom values.
* @param {string} filePath - File path
*/
export function setupBaseModelEditing(filePath) {
@@ -257,98 +325,251 @@ export function setupBaseModelEditing(filePath) {
// Store the original value to check for changes later
const originalValue = baseModelContent.textContent.trim();
// Create dropdown selector to replace the base model content
const currentValue = originalValue;
const dropdown = document.createElement('select');
dropdown.className = 'base-model-selector';
// ── Build the full option list ────────────────────────────────────────
const allModels = []; // { value, label, category }
const categorizedModels = new Set();
// Flag to track if a change was made
let valueChanged = false;
// Add options from BASE_MODEL_CATEGORIES constants
const baseModelCategories = BASE_MODEL_CATEGORIES;
// Create option groups for better organization
Object.entries(baseModelCategories).forEach(([category, models]) => {
const group = document.createElement('optgroup');
group.label = category;
Object.entries(BASE_MODEL_CATEGORIES).forEach(([category, models]) => {
models.forEach(model => {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
option.selected = model === currentValue;
group.appendChild(option);
allModels.push({ value: model, label: model, category });
categorizedModels.add(model);
});
});
const mergedModels = getMergedBaseModels();
const uncategorizedModels = mergedModels.filter(model => !categorizedModels.has(model));
if (uncategorizedModels.length > 0) {
uncategorizedModels.forEach(model => {
allModels.push({ value: model, label: model, category: 'Other (API)' });
});
}
// ── Filename-based inference ──────────────────────────────────────────
const fileName = (document.querySelector('.file-name-content')?.textContent || '') + ' ' +
(document.querySelector('.model-name-content')?.textContent || '');
const inferredModels = inferBaseModelsFromFilename(fileName);
const inferredSet = new Set(inferredModels);
// ── Build search widget DOM ───────────────────────────────────────────
const wrapper = document.createElement('div');
wrapper.className = 'base-model-search-wrapper';
// Search input row
const inputWrapper = document.createElement('div');
inputWrapper.className = 'base-model-search-input-wrapper';
const searchIcon = document.createElement('i');
searchIcon.className = 'fas fa-search search-icon';
searchIcon.setAttribute('aria-hidden', 'true');
inputWrapper.appendChild(searchIcon);
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'base-model-search-input';
searchInput.placeholder = translate('modals.model.metadata.baseModelSearchPlaceholder', {}, 'Search base model…');
searchInput.autocomplete = 'off';
searchInput.spellcheck = false;
inputWrapper.appendChild(searchInput);
wrapper.appendChild(inputWrapper);
// Dropdown list
const dropdown = document.createElement('div');
dropdown.className = 'base-model-dropdown';
wrapper.appendChild(dropdown);
// ── Render ────────────────────────────────────────────────────────────
function renderDropdown(filterText) {
const lowerFilter = (filterText || '').toLowerCase().trim();
dropdown.innerHTML = '';
let hasVisibleItems = false;
const fragment = document.createDocumentFragment();
// 1. Suggested section (filename-inferred, filtered by search)
let suggestedToShow = inferredModels;
if (lowerFilter) {
suggestedToShow = inferredModels.filter(m =>
m.toLowerCase().includes(lowerFilter)
);
}
if (suggestedToShow.length > 0) {
const section = document.createElement('div');
section.className = 'base-model-dropdown-section';
const header = document.createElement('div');
header.className = 'base-model-dropdown-header suggested-header';
header.innerHTML = '<i class="fas fa-star" aria-hidden="true"></i> ' +
translate('modals.model.metadata.baseModelSuggested', {}, 'Suggested');
section.appendChild(header);
suggestedToShow.forEach(model => {
const item = document.createElement('div');
item.className = 'base-model-dropdown-item';
if (model === originalValue) item.classList.add('selected');
item.dataset.value = model;
item.textContent = model;
section.appendChild(item);
hasVisibleItems = true;
});
fragment.appendChild(section);
}
// 2. Categorized options (deduplicated against suggestions)
const categoryMap = {};
allModels.forEach(m => {
if (inferredSet.has(m.value)) return; // already shown in Suggested
if (lowerFilter && !m.label.toLowerCase().includes(lowerFilter)) return;
if (!categoryMap[m.category]) categoryMap[m.category] = [];
categoryMap[m.category].push(m);
});
dropdown.appendChild(group);
Object.entries(categoryMap).forEach(([category, items]) => {
if (items.length === 0) return;
const section = document.createElement('div');
section.className = 'base-model-dropdown-section';
const header = document.createElement('div');
header.className = 'base-model-dropdown-header';
header.textContent = category;
section.appendChild(header);
items.forEach(m => {
const item = document.createElement('div');
item.className = 'base-model-dropdown-item';
if (m.value === originalValue) item.classList.add('selected');
item.dataset.value = m.value;
item.textContent = m.label;
section.appendChild(item);
hasVisibleItems = true;
});
fragment.appendChild(section);
});
// 3. Empty state
if (!hasVisibleItems) {
const empty = document.createElement('div');
empty.className = 'base-model-dropdown-empty';
empty.textContent = translate('modals.model.metadata.baseModelNoMatch', {}, 'No matching base models');
fragment.appendChild(empty);
}
dropdown.appendChild(fragment);
// Scroll the selected item into view
const selected = dropdown.querySelector('.base-model-dropdown-item.selected');
if (selected) {
selected.scrollIntoView({ block: 'nearest' });
}
}
// Initial render — show everything
renderDropdown('');
// ── Events ────────────────────────────────────────────────────────────
let filterTimeout;
searchInput.addEventListener('input', () => {
clearTimeout(filterTimeout);
filterTimeout = setTimeout(() => renderDropdown(searchInput.value), 50);
});
// Replace content with dropdown
// Click to select
dropdown.addEventListener('click', (e) => {
const item = e.target.closest('.base-model-dropdown-item');
if (!item) return;
baseModelContent.textContent = item.dataset.value;
cleanup();
const finalValue = baseModelContent.textContent.trim();
if (finalValue !== originalValue) {
saveBaseModel(
getActiveModalFilePath(baseModelContent.dataset.filePath),
originalValue
);
}
});
// Replace content with search widget
baseModelContent.style.display = 'none';
baseModelDisplay.insertBefore(dropdown, editBtn);
// Hide edit button during editing
editBtn.style.display = 'none';
baseModelDisplay.insertBefore(wrapper, editBtn);
searchInput.focus();
// Focus the dropdown
dropdown.focus();
// Handle dropdown change
dropdown.addEventListener('change', function() {
const selectedModel = this.value;
baseModelContent.textContent = selectedModel;
// Mark that a change was made if the value differs from original
if (selectedModel !== originalValue) {
valueChanged = true;
} else {
valueChanged = false;
// ── Cleanup ───────────────────────────────────────────────────────────
function cleanup() {
if (wrapper.parentNode === baseModelDisplay) {
baseModelDisplay.removeChild(wrapper);
}
});
// Function to save changes and exit edit mode
const saveAndExit = function() {
// Check if dropdown still exists and remove it
if (dropdown && dropdown.parentNode === baseModelDisplay) {
baseModelDisplay.removeChild(dropdown);
}
// Show the content and edit button
baseModelContent.style.display = '';
editBtn.style.display = '';
// Remove editing class
baseModelDisplay.classList.remove('editing');
// Only save if the value has actually changed
if (valueChanged || baseModelContent.textContent.trim() !== originalValue) {
const resolvedPath = getActiveModalFilePath(baseModelContent.dataset.filePath);
saveBaseModel(resolvedPath, originalValue);
}
// Remove this event listener
document.removeEventListener('click', outsideClickHandler);
};
}
// Handle outside clicks to save and exit
// Outside click save typed/custom value if any
const outsideClickHandler = function(e) {
// If click is outside the dropdown and base model display
if (!baseModelDisplay.contains(e.target)) {
saveAndExit();
if (wrapper.contains(e.target)) return;
// If user typed a custom value (not just empty), apply it
const typedValue = searchInput.value.trim();
if (typedValue) {
baseModelContent.textContent = typedValue;
}
cleanup();
const finalValue = baseModelContent.textContent.trim();
if (finalValue !== originalValue) {
saveBaseModel(
getActiveModalFilePath(baseModelContent.dataset.filePath),
originalValue
);
}
};
// Add delayed event listener for outside clicks
// Defer listener to avoid the opening click itself
setTimeout(() => {
document.addEventListener('click', outsideClickHandler);
}, 0);
// Also handle dropdown blur event
dropdown.addEventListener('blur', function(e) {
// Only save if the related target is not the edit button or inside the baseModelDisplay
if (!baseModelDisplay.contains(e.relatedTarget)) {
saveAndExit();
// Keyboard navigation
searchInput.addEventListener('keydown', function onKeydown(e) {
const items = Array.from(dropdown.querySelectorAll('.base-model-dropdown-item'));
const activeIdx = items.findIndex(el => el.classList.contains('active'));
if (e.key === 'ArrowDown') {
e.preventDefault();
items.forEach(el => el.classList.remove('active'));
const next = Math.min(activeIdx + 1, items.length - 1);
if (items[next]) {
items[next].classList.add('active');
items[next].scrollIntoView({ block: 'nearest' });
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
items.forEach(el => el.classList.remove('active'));
const prev = Math.max(activeIdx - 1, 0);
if (items[prev]) {
items[prev].classList.add('active');
items[prev].scrollIntoView({ block: 'nearest' });
}
} else if (e.key === 'Enter') {
e.preventDefault();
const activeItem = items.find(el => el.classList.contains('active'));
if (activeItem) {
activeItem.click();
} else if (searchInput.value.trim()) {
// Custom value typed
baseModelContent.textContent = searchInput.value.trim();
cleanup();
const finalValue = baseModelContent.textContent.trim();
if (finalValue !== originalValue) {
saveBaseModel(
getActiveModalFilePath(baseModelContent.dataset.filePath),
originalValue
);
}
}
} else if (e.key === 'Escape') {
e.preventDefault();
baseModelContent.textContent = originalValue;
cleanup();
}
});
});
+14
View File
@@ -360,6 +360,11 @@ export async function showModelModal(model, modelType) {
const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
</div>`.trim() : '';
const escapedHfUrl = modelWithFullData.hf_url ? escapeAttribute(modelWithFullData.hf_url) : '';
const viewOnHuggingFaceAction = escapedHfUrl ? `
<div class="civitai-view" title="${translate('modals.model.actions.viewOnHuggingFace', {}, 'View on Hugging Face')}" data-action="view-huggingface" data-hf-url="${escapedHfUrl}">
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnHuggingFaceText', {}, 'View on Hugging Face')}
</div>`.trim() : '';
const creatorInfoAction = modelWithFullData.civitai?.creator ? `
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
@@ -377,6 +382,9 @@ export async function showModelModal(model, modelType) {
if (viewOnCivitaiAction) {
creatorActionItems.push(indentMarkup(viewOnCivitaiAction, 24));
}
if (viewOnHuggingFaceAction) {
creatorActionItems.push(indentMarkup(viewOnHuggingFaceAction, 24));
}
if (creatorInfoAction) {
creatorActionItems.push(indentMarkup(creatorInfoAction, 24));
}
@@ -752,6 +760,7 @@ export async function showModelModal(model, modelType) {
modelId: civitaiModelId,
currentVersionId: civitaiVersionId,
currentBaseModel: modelWithFullData.base_model,
modelName: model.model_name,
onUpdateStatusChange: handleUpdateStatusChange,
});
setupEditableFields(modelWithFullData.file_path, modelType);
@@ -868,6 +877,11 @@ function setupEventHandlers(filePath, modelType) {
case 'view-civitai':
openCivitai(target.dataset.filepath);
break;
case 'view-huggingface':
if (target.dataset.hfUrl) {
window.open(target.dataset.hfUrl, '_blank', 'noopener,noreferrer');
}
break;
case 'view-creator':
const username = target.dataset.username;
if (username) {
@@ -6,6 +6,7 @@ import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js';
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
import { formatFileSize } from './utils.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
@@ -306,7 +307,7 @@ function getToggleTooltipText(mode) {
}
function getDefaultDisplayMode() {
const strategy = state?.global?.settings?.update_flag_strategy;
const strategy = state?.global?.settings?.version_grouping;
return strategy === DISPLAY_FILTER_MODES.SAME_BASE
? DISPLAY_FILTER_MODES.SAME_BASE
: DISPLAY_FILTER_MODES.ANY;
@@ -338,7 +339,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
return false;
}
const strategy = state?.global?.settings?.update_flag_strategy;
const strategy = state?.global?.settings?.version_grouping;
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
@@ -744,7 +745,7 @@ function renderToolbar(record, toolbarState = {}) {
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-versions-action="toggle-model-ignore">
${escapeHtml(ignoreText)}
</button>
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Coming soon'))}" disabled>
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Show all local versions of this model on the main page'))}">
${escapeHtml(viewLocalText)}
</button>
</div>
@@ -792,6 +793,7 @@ export function initVersionsTab({
modelId,
currentVersionId,
currentBaseModel,
modelName,
onUpdateStatusChange,
}) {
const pane = document.querySelector(`#${modalId} #versions-tab`);
@@ -1019,6 +1021,39 @@ export function initVersionsTab({
render(controller.record);
}
function handleViewLocalVersions() {
if (!controller.record || !modelId) {
return;
}
// Determine base model filter based on current display mode
const baseModelInfo = getCurrentVersionBaseModel(controller.record, normalizedCurrentVersionId);
const isFilteringActive =
displayMode === DISPLAY_FILTER_MODES.SAME_BASE &&
Boolean(baseModelInfo.normalized);
// Write filter params to sessionStorage (page-scoped)
setSessionItem('vlm_model_id', String(modelId));
setSessionItem('vlm_model_name', modelName || String(modelId));
setSessionItem('vlm_page_type', modelType);
if (isFilteringActive) {
// Use raw (non-normalized) base model for exact backend matching
setSessionItem('vlm_base_model', baseModelInfo.raw);
} else {
removeSessionItem('vlm_base_model');
}
// Close the modal and navigate via no-reload VLM flow
modalManager.closeModal(modalId);
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
window.pageControls.triggerVlmView(
modelId,
modelName || String(modelId),
isFilteringActive ? baseModelInfo.raw : undefined,
modelType
);
}
}
async function handleToggleVersionIgnore(button, versionId) {
if (!controller.record) {
return;
@@ -1348,6 +1383,10 @@ export function initVersionsTab({
event.preventDefault();
handleToggleVersionDisplayMode();
break;
case 'view-local':
event.preventDefault();
handleViewLocalVersions();
break;
default:
break;
}
@@ -3,7 +3,7 @@
* Media-specific utility functions for showcase components
* (Moved from uiHelpers.js to better organize code)
*/
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
import { showToast, copyToClipboard, getNSFWLevelName, sendPromptToWorkflow, stripLoraTags, sendGenParamsToWorkflow } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js';
import { getModelApiClient } from '../../../api/modelApiFactory.js';
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
@@ -318,6 +318,74 @@ export function initMetadataPanelHandlers(container) {
});
});
// Handle send prompt buttons
const sendBtns = metadataPanel.querySelectorAll('.send-prompt-btn');
sendBtns.forEach(sendBtn => {
const promptIndex = sendBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
sendBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
let promptText = promptElement.textContent || '';
if (!promptText.trim()) {
showToast('toast.recipes.noPromptToSend', {}, 'warning');
return;
}
// Respect strip <lora> setting from global state
if (state.global.settings?.strip_lora_on_copy) {
promptText = stripLoraTags(promptText);
}
sendPromptToWorkflow(promptText);
});
});
// Handle send params buttons
const paramsBtn = metadataPanel.querySelector('.send-params-btn');
if (paramsBtn) {
paramsBtn.addEventListener('click', async (e) => {
e.stopPropagation();
// Collect gen params from the param-tag elements
const tagsContainer = wrapper.querySelector('.params-tags');
if (!tagsContainer) return;
const paramTags = tagsContainer.querySelectorAll('.param-tag');
const genParams = {};
// Map display labels to genParams keys
const labelToKey = {
'Seed': 'seed',
'Steps': 'steps',
'Sampler': 'sampler',
'CFG': 'cfg_scale',
};
paramTags.forEach(tag => {
const nameEl = tag.querySelector('.param-name');
const valueEl = tag.querySelector('.param-value');
if (!nameEl || !valueEl) return;
const label = nameEl.textContent.replace(':', '').trim();
const key = labelToKey[label];
if (key) {
genParams[key] = valueEl.textContent.trim();
}
});
if (Object.keys(genParams).length === 0) {
showToast('No sendable parameters found', {}, 'warning');
return;
}
await sendGenParamsToWorkflow(genParams);
});
}
// Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
@@ -28,14 +28,24 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
if (hasParams) {
content += `
<div class="params-tags">
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
<div class="metadata-row params-row">
<div class="param-header">
<span class="metadata-label">Params:</span>
<div class="param-actions">
<button class="send-params-btn" title="Send Params to Workflow">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
<div class="params-tags">
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
</div>
</div>
`;
}
@@ -53,12 +63,19 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
prompt = escapeHtml(prompt);
content += `
<div class="metadata-row prompt-row">
<span class="metadata-label">Prompt:</span>
<div class="param-header">
<span class="metadata-label">Prompt:</span>
<div class="param-actions">
<button class="send-prompt-btn" data-prompt-index="${promptIndex}" title="Send Prompt to Workflow">
<i class="fas fa-paper-plane"></i>
</button>
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}" title="Copy Prompt">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="metadata-prompt-wrapper">
<div class="metadata-prompt">${prompt}</div>
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="hidden-prompt" id="prompt-${promptIndex}" style="display:none;">${prompt}</div>
@@ -69,12 +86,19 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
negativePrompt = escapeHtml(negativePrompt);
content += `
<div class="metadata-row prompt-row">
<span class="metadata-label">Negative Prompt:</span>
<div class="param-header">
<span class="metadata-label">Negative Prompt:</span>
<div class="param-actions">
<button class="send-prompt-btn" data-prompt-index="${negPromptIndex}" title="Send Negative Prompt to Workflow">
<i class="fas fa-paper-plane"></i>
</button>
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}" title="Copy Negative Prompt">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="metadata-prompt-wrapper">
<div class="metadata-prompt">${negativePrompt}</div>
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="hidden-prompt" id="prompt-${negPromptIndex}" style="display:none;">${negativePrompt}</div>
+196
View File
@@ -0,0 +1,196 @@
/**
* AgentManager WebSocket listener for agent skill progress events.
*
* Connects to the generic WebSocket endpoint and filters for
* `type: "agent_progress"` messages. Dispatches progress and completion
* events to registered callbacks.
*/
class AgentManager {
constructor() {
this.websocket = null;
this.progressCallbacks = [];
this.completeCallbacks = [];
this.errorCallbacks = [];
this.connected = false;
}
/**
* Connect to the WebSocket endpoint for agent progress events.
* Safe to call multiple times won't reconnect if already connected.
*/
connect() {
if (this.connected && this.websocket?.readyState === WebSocket.OPEN) {
return;
}
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
try {
this.websocket = new WebSocket(
`${wsProtocol}${window.location.host}/ws/fetch-progress`
);
} catch (e) {
console.error('AgentManager: Failed to create WebSocket:', e);
return;
}
this.websocket.onopen = () => {
this.connected = true;
console.debug('AgentManager: WebSocket connected');
};
this.websocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== 'agent_progress') return;
this._dispatch(data);
} catch (e) {
// Not JSON or wrong format — ignore
}
};
this.websocket.onerror = (error) => {
console.error('AgentManager: WebSocket error:', error);
this.connected = false;
};
this.websocket.onclose = () => {
this.connected = false;
console.debug('AgentManager: WebSocket closed');
};
}
/**
* Dispatch a parsed agent event to the appropriate callbacks.
* @param {Object} data - The parsed WebSocket message
*/
_dispatch(data) {
const { status, skill } = data;
if (status === 'error') {
this.errorCallbacks.forEach((cb) => {
try {
cb(data);
} catch (e) {
console.error('AgentManager error callback failed:', e);
}
});
return;
}
if (status === 'completed') {
this.completeCallbacks.forEach((cb) => {
try {
cb(data);
} catch (e) {
console.error('AgentManager complete callback failed:', e);
}
});
return;
}
// started, processing — general progress
this.progressCallbacks.forEach((cb) => {
try {
cb(data);
} catch (e) {
console.error('AgentManager progress callback failed:', e);
}
});
}
/**
* Register a callback for progress events (started, processing).
* @param {Function} callback - Receives the event data
*/
onProgress(callback) {
this.progressCallbacks.push(callback);
}
/**
* Register a callback for completion events.
* @param {Function} callback - Receives the event data
*/
onComplete(callback) {
this.completeCallbacks.push(callback);
}
/**
* Register a callback for error events.
* @param {Function} callback - Receives the event data
*/
onError(callback) {
this.errorCallbacks.push(callback);
}
/**
* Clear all registered callbacks.
*/
clearCallbacks() {
this.progressCallbacks = [];
this.completeCallbacks = [];
this.errorCallbacks = [];
}
/**
* Execute an agent skill on the provided model paths.
*
* @param {string} skillName - The skill to execute
* @param {string[]} modelPaths - Model file paths to process
* @returns {Promise<Object>} The response JSON
*/
async executeSkill(skillName, modelPaths) {
const response = await fetch(
`/api/lm/agent/execute/${encodeURIComponent(skillName)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_paths: modelPaths }),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.error || `HTTP ${response.status}: ${response.statusText}`
);
}
return response.json();
}
/**
* Check if the LLM provider is configured.
*
* Returns true when both an API key and a model name are set.
*
* @returns {Promise<boolean>}
*/
async isLlmConfigured() {
try {
const response = await fetch('/api/lm/settings');
if (!response.ok) return false;
const data = await response.json();
const provider = data.settings?.llm_provider;
const hasModel = !!data.settings?.llm_model;
const hasKey = !!data.settings?.llm_api_key;
return hasModel && (hasKey || provider === 'ollama');
} catch {
return false;
}
}
/**
* Get the list of available agent skills.
*
* @returns {Promise<Array>}
*/
async listSkills() {
const response = await fetch('/api/lm/agent/skills');
if (!response.ok) return [];
const data = await response.json();
return data.skills || [];
}
}
// Export as singleton
export const agentManager = new AgentManager();
+176 -33
View File
@@ -21,6 +21,7 @@ export class BulkManager {
this.isMarqueeActive = false;
this.isDragging = false;
this.marqueeStart = { x: 0, y: 0 };
this.marqueeStartDoc = { x: 0, y: 0 }; // Marquee start in document coordinates
this.marqueeElement = null;
this.initialSelectedModels = new Set();
@@ -29,6 +30,11 @@ export class BulkManager {
this.mouseDownTime = 0;
this.mouseDownPosition = { x: 0, y: 0 };
// Auto-scroll properties for marquee
this.lastClientX = 0;
this.lastClientY = 0;
this.autoScrollRaf = null;
// Model type specific action configurations
this.actionConfig = {
[MODEL_TYPES.LORA]: {
@@ -168,7 +174,10 @@ export class BulkManager {
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
if (this.isMarqueeActive) {
this.lastClientX = e.clientX;
this.lastClientY = e.clientY;
this.updateMarqueeSelection(e);
this.startAutoScroll();
} else if (this.mouseDownTime && !this.isDragging) {
// Check if we've moved enough to consider it a drag
const dx = e.clientX - this.mouseDownPosition.x;
@@ -237,6 +246,7 @@ export class BulkManager {
* Clean up event handlers
*/
cleanup() {
this.stopAutoScroll();
eventManager.removeAllHandlersForSource('bulkManager-keyboard');
eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
eventManager.removeAllHandlersForSource('bulkManager-marquee-move');
@@ -1727,10 +1737,15 @@ export class BulkManager {
* @param {boolean} isDragging - Whether this is triggered from a drag operation
*/
startMarqueeSelection(e, isDragging = false) {
// Store initial mouse position
// Store initial mouse position (viewport coordinates for visual element)
this.marqueeStart.x = this.mouseDownPosition.x;
this.marqueeStart.y = this.mouseDownPosition.y;
// Store initial mouse position in document coordinates (for logical selection)
const container = document.querySelector('.page-content');
this.marqueeStartDoc.x = this.mouseDownPosition.x + (container?.scrollLeft || 0);
this.marqueeStartDoc.y = this.mouseDownPosition.y + (container?.scrollTop || 0);
// Store initial selection state
this.initialSelectedModels = new Set(state.selectedModels);
@@ -1776,46 +1791,67 @@ export class BulkManager {
*/
updateMarqueeSelection(e) {
if (!this.marqueeElement) return;
const currentX = e.clientX;
const currentY = e.clientY;
// Calculate rectangle bounds
const left = Math.min(this.marqueeStart.x, currentX);
const top = Math.min(this.marqueeStart.y, currentY);
const width = Math.abs(currentX - this.marqueeStart.x);
const height = Math.abs(currentY - this.marqueeStart.y);
// Update marquee element position and size
this.marqueeElement.style.left = left + 'px';
this.marqueeElement.style.top = top + 'px';
this.marqueeElement.style.width = width + 'px';
this.marqueeElement.style.height = height + 'px';
// Check which cards intersect with marquee
this.updateCardSelection(left, top, left + width, top + height);
this.updateMarqueeSelectionFromPosition(e.clientX, e.clientY);
}
/**
* Update card selection based on marquee bounds
* Update marquee from raw client coordinates (used by both mousemove and auto-scroll loop)
*/
updateCardSelection(left, top, right, bottom) {
const cards = document.querySelectorAll('.model-card');
updateMarqueeSelectionFromPosition(clientX, clientY) {
if (!this.marqueeElement) return;
const container = document.querySelector('.page-content');
const scrollX = container?.scrollLeft || 0;
const scrollY = container?.scrollTop || 0;
// Current position in document coordinates
const currentDocX = clientX + scrollX;
const currentDocY = clientY + scrollY;
// Calculate marquee rectangle in document coordinates
const docLeft = Math.min(this.marqueeStartDoc.x, currentDocX);
const docTop = Math.min(this.marqueeStartDoc.y, currentDocY);
const docRight = Math.max(this.marqueeStartDoc.x, currentDocX);
const docBottom = Math.max(this.marqueeStartDoc.y, currentDocY);
// Update visual marquee element (position: fixed, so subtract scroll offset)
this.marqueeElement.style.left = (docLeft - scrollX) + 'px';
this.marqueeElement.style.top = (docTop - scrollY) + 'px';
this.marqueeElement.style.width = (docRight - docLeft) + 'px';
this.marqueeElement.style.height = (docBottom - docTop) + 'px';
// Check which cards intersect with marquee
this.updateCardSelection(docLeft, docTop, docRight, docBottom);
}
/**
* Update card selection based on marquee bounds (document coordinates).
* Uses dual detection: DOM cards for visible ones + VirtualScroller layout for off-screen cards.
*/
updateCardSelection(docLeft, docTop, docRight, docBottom) {
const vs = state.virtualScroller;
const container = document.querySelector('.page-content');
const scrollX = container?.scrollLeft || 0;
const scrollY = container?.scrollTop || 0;
const newSelection = new Set(this.initialSelectedModels);
const visibleFilepaths = new Set();
cards.forEach(card => {
const rect = card.getBoundingClientRect();
// Check if card intersects with marquee rectangle
const intersects = !(rect.right < left ||
rect.left > right ||
rect.bottom < top ||
rect.top > bottom);
// Step 1: Process visible DOM cards using getBoundingClientRect + scroll offset
document.querySelectorAll('.model-card').forEach(card => {
const filepath = card.dataset.filepath;
if (!filepath) return;
visibleFilepaths.add(filepath);
const rect = card.getBoundingClientRect();
const cardLeft = rect.left + scrollX;
const cardTop = rect.top + scrollY;
const cardRight = rect.right + scrollX;
const cardBottom = rect.bottom + scrollY;
const intersects = !(cardRight < docLeft || cardLeft > docRight ||
cardBottom < docTop || cardTop > docBottom);
if (intersects) {
// Add to selection if intersecting
newSelection.add(filepath);
card.classList.add('selected');
@@ -1825,12 +1861,43 @@ export class BulkManager {
this.updateMetadataCacheFromCard(filepath, card);
}
} else if (!this.initialSelectedModels.has(filepath)) {
// Remove from selection if not intersecting and wasn't initially selected
newSelection.delete(filepath);
card.classList.remove('selected');
}
});
// Step 2: Process off-screen cards via VirtualScroller layout calculation.
// Since VirtualScroller removes off-screen DOM elements, we compute
// each card's position from its index and the VS layout parameters.
if (vs?.gridElement && vs.items && vs.columnsCount > 0) {
const gridRect = vs.gridElement.getBoundingClientRect();
// Grid origin in scroll-container content coordinates
const originX = gridRect.left + scrollX;
const originY = gridRect.top + scrollY;
for (let i = 0; i < vs.items.length; i++) {
const filepath = vs.items[i]?.file_path;
if (!filepath || visibleFilepaths.has(filepath)) continue;
const row = Math.floor(i / vs.columnsCount);
const col = i % vs.columnsCount;
const cLeft = originX + col * (vs.itemWidth + vs.columnGap);
const cTop = originY + (vs.containerPaddingTop || 0) + row * (vs.itemHeight + (vs.rowGap || 0));
const cRight = cLeft + vs.itemWidth;
const cBottom = cTop + vs.itemHeight;
const intersects = !(cRight < docLeft || cLeft > docRight ||
cBottom < docTop || cTop > docBottom);
if (intersects) {
newSelection.add(filepath);
} else if (!this.initialSelectedModels.has(filepath)) {
newSelection.delete(filepath);
}
}
}
// Update global selection state
state.selectedModels = newSelection;
@@ -1849,6 +1916,9 @@ export class BulkManager {
this.isDragging = false;
this.mouseDownTime = 0;
// Stop any active auto-scroll
this.stopAutoScroll();
// Update event manager state
eventManager.setState('marqueeActive', false);
@@ -1874,6 +1944,79 @@ export class BulkManager {
// Clear initial selection state
this.initialSelectedModels.clear();
}
/**
* Start auto-scroll loop when mouse approaches viewport edge during marquee
*/
startAutoScroll() {
if (this.autoScrollRaf) return;
this.autoScrollLoop();
}
/**
* Stop auto-scroll loop
*/
stopAutoScroll() {
if (this.autoScrollRaf) {
cancelAnimationFrame(this.autoScrollRaf);
this.autoScrollRaf = null;
}
}
/**
* Auto-scroll loop: scrolls the page when mouse is near viewport edges
* and re-evaluates marquee selection after each scroll.
*/
autoScrollLoop() {
if (!this.isMarqueeActive) {
this.autoScrollRaf = null;
return;
}
const container = document.querySelector('.page-content');
if (!container) {
this.autoScrollRaf = null;
return;
}
const MARGIN = 30; // Px from edge to trigger scroll
const BASE_SPEED = 12; // Pixels per frame at edge boundary
const MAX_SPEED = 40; // Maximum scroll speed
const rect = container.getBoundingClientRect();
let dx = 0;
let dy = 0;
// Vertical auto-scroll - speed increases the further the cursor is past the edge
if (this.lastClientY !== undefined) {
if (this.lastClientY < rect.top + MARGIN) {
const dist = Math.max(0, (rect.top + MARGIN) - this.lastClientY);
dy = -Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
} else if (this.lastClientY > rect.bottom - MARGIN) {
const dist = Math.max(0, this.lastClientY - (rect.bottom - MARGIN));
dy = Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
}
}
// Horizontal auto-scroll
if (this.lastClientX !== undefined) {
if (this.lastClientX < rect.left + MARGIN) {
const dist = Math.max(0, (rect.left + MARGIN) - this.lastClientX);
dx = -Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
} else if (this.lastClientX > rect.right - MARGIN) {
const dist = Math.max(0, this.lastClientX - (rect.right - MARGIN));
dx = Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
}
}
if (dx !== 0 || dy !== 0) {
container.scrollBy(dx, dy);
// Re-evaluate marquee selection with the new scroll position
this.updateMarqueeSelectionFromPosition(this.lastClientX, this.lastClientY);
this.autoScrollRaf = requestAnimationFrame(() => this.autoScrollLoop());
} else {
this.autoScrollRaf = null;
}
}
}
export const bulkManager = new BulkManager();
+444 -22
View File
@@ -7,6 +7,7 @@ import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { translate } from '../utils/i18nHelpers.js';
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
import { formatFileSize } from '../utils/formatters.js';
export class DownloadManager {
constructor() {
@@ -27,6 +28,10 @@ export class DownloadManager {
this.isBatchMode = false;
this.editingBatchIndex = -1;
// HF download state
this.hfRepoId = null;
this.hfSelectedFiles = [];
this.loadingManager = new LoadingManager();
this.folderTreeManager = new FolderTreeManager();
this.folderClickHandler = null;
@@ -44,6 +49,8 @@ export class DownloadManager {
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
this.handleBackToUrlFromBatch = this.backToUrlFromBatch.bind(this);
this.handleNextFromBatch = this.nextFromBatch.bind(this);
}
showDownloadModal() {
@@ -99,6 +106,8 @@ export class DownloadManager {
// Default path toggle handler
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
}
updateModalLabels() {
@@ -160,6 +169,10 @@ export class DownloadManager {
// Reset default path toggle
this.loadDefaultPathSetting();
// Reset HF state
this.hfRepoId = null;
this.hfSelectedFiles = [];
}
async retrieveVersionsForModel(modelId, source = null) {
@@ -180,6 +193,29 @@ export class DownloadManager {
return;
}
// Detect URL types — all URLs must share the same source type
const urlTypes = urls.map(u => DownloadManager.detectUrlType(u));
const isHf = urlTypes.every(t => t && (t.type === 'hf-resolve' || t.type === 'hf-repo'));
const isCivitai = urlTypes.every(t => t && t.type === 'civitai');
if (!isHf && !isCivitai) {
const allValid = urlTypes.every(t => t !== null);
if (!allValid) {
errorElement.textContent = translate('modals.download.errors.invalidUrl');
return;
}
// Mixed sources not supported in one batch
if (urls.length > 1) {
errorElement.textContent = translate('modals.download.errors.mixedSources');
return;
}
}
if (isHf) {
return this._validateAndFetchHf(urls, errorElement);
}
// --- Original CivitAI flow below ---
if (urls.length === 1) {
this.isBatchMode = false;
try {
@@ -271,6 +307,112 @@ export class DownloadManager {
this.showBatchPreviewStep();
}
// ---- Hugging Face download flow ----
async _validateAndFetchHf(urls, errorElement) {
if (urls.length === 1) {
const info = DownloadManager.detectUrlType(urls[0]);
// Direct file resolve URL → skip file selection, go to location
if (info.type === 'hf-resolve') {
this.isBatchMode = false;
this.hfRepoId = info.repo;
this.hfSelectedFiles = [info.filename];
this.source = 'huggingface';
this.proceedToLocation();
return;
}
// Repo URL → fetch file list and convert to batch items
try {
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingRepoFiles'));
const files = await this.apiClient.fetchHfRepoFiles(info.repo);
if (!files || files.length === 0) {
throw new Error(translate('modals.download.errors.noModelFiles'));
}
this.isBatchMode = true;
this.batchModels = [];
this.source = 'huggingface';
for (const file of files) {
this.batchModels.push({
url: urls[0],
source: 'huggingface',
repo: info.repo,
filename: file.filename,
revision: 'main',
displayName: file.filename,
fileSizeBytes: file.size,
selectedVersion: true,
versions: [],
checked: false,
error: null,
});
}
this.showBatchPreviewStep();
} catch (err) {
errorElement.textContent = err.message;
} finally {
this.loadingManager.hide();
}
return;
}
// Multiple HF URLs → batch mode: flatten all files from all repos
this.isBatchMode = true;
this.batchModels = [];
this.source = 'huggingface';
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingRepoFiles'));
for (const url of urls) {
const info = DownloadManager.detectUrlType(url);
if (!info) {
this.batchModels.push({ url, error: 'Invalid URL', versions: [], selectedVersion: null });
continue;
}
if (info.type === 'hf-resolve') {
this.batchModels.push({
url,
source: 'huggingface',
repo: info.repo,
filename: info.filename,
revision: info.revision || 'main',
displayName: info.filename,
selectedVersion: true,
versions: [],
checked: false,
error: null,
});
} else if (info.type === 'hf-repo') {
try {
const files = await this.apiClient.fetchHfRepoFiles(info.repo);
if (!files || files.length === 0) {
this.batchModels.push({ url, error: 'No model files found', versions: [], selectedVersion: null });
continue;
}
// Flatten: create one batch item per file, all checked by default
for (const file of files) {
this.batchModels.push({
url,
source: 'huggingface',
repo: info.repo,
filename: file.filename,
revision: 'main',
displayName: file.filename,
fileSizeBytes: file.size,
selectedVersion: true,
versions: [],
checked: false,
error: null,
});
}
} catch (err) {
this.batchModels.push({ url, error: err.message, versions: [], selectedVersion: null });
}
}
}
this.loadingManager.hide();
this.showBatchPreviewStep();
}
async fetchVersionsForCurrentModel() {
const errorElement = document.getElementById('urlError');
if (errorElement) {
@@ -311,6 +453,60 @@ export class DownloadManager {
return { modelId: null, modelVersionId: null, source: null };
}
/**
* Detect the source type of a download URL.
* @param {string} url
* @returns {{ type: string, repo?: string, filename?: string, revision?: string } | null}
* type: 'civitai' | 'civarchive' | 'hf-resolve' | 'hf-repo' | 'direct-http'
*/
static detectUrlType(url) {
const trimmed = url.trim();
if (!trimmed) return null;
// CivitAI
if (/civitai\.com\/models\//i.test(trimmed) || /civitaiarchive|civarchive/i.test(trimmed)) {
// Will be parsed by existing CivitAI logic
return { type: 'civitai' };
}
// Hugging Face resolve URL → direct file
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/resolve\/([^/\s]+)\/(.+)/i);
if (hfResolveMatch) {
return {
type: 'hf-resolve',
repo: hfResolveMatch[1],
revision: hfResolveMatch[2],
filename: hfResolveMatch[3],
};
}
// Hugging Face repo URL (huggingface.co/user/repo or bare user/repo path)
// Require huggingface.co prefix for full URLs; bare user/repo only without ://
const hfRepoMatch = trimmed.match(
trimmed.includes('://')
? /^https?:\/\/huggingface\.co\/([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(?:\/?$|$)/
: /^([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)$/
);
if (hfRepoMatch) {
// Reject path-traversal patterns like "../.." or "user/.."
const parts = hfRepoMatch[1].split('/');
if (parts.some(p => p === '.' || p === '..')) {
return null;
}
return {
type: 'hf-repo',
repo: hfRepoMatch[1],
};
}
// Direct HTTP(S) URL (non-HF)
if (/^https?:\/\//i.test(trimmed)) {
return { type: 'direct-http' };
}
return null;
}
extractModelId(url) {
const result = DownloadManager.parseModelUrl(url);
this.modelVersionId = result.modelVersionId;
@@ -351,7 +547,7 @@ export class DownloadManager {
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
// Count model-type files per version
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
const fileSize = version.modelSizeKB ?
(version.modelSizeKB / 1024).toFixed(2) :
@@ -478,7 +674,7 @@ export class DownloadManager {
if (!version) return;
this.currentVersion = version;
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
document.getElementById('versionStep').style.display = 'none';
document.getElementById('fileSelectionStep').style.display = 'block';
@@ -534,7 +730,7 @@ export class DownloadManager {
const version = this.currentVersion;
if (!version) return;
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
document.getElementById('fileSelectionStep').style.display = 'none';
@@ -559,8 +755,8 @@ export class DownloadManager {
return;
}
// In single-URL mode, validate version selection
if (!this.isBatchMode) {
// In single-URL mode, validate version selection (skip for HF)
if (!this.isBatchMode && this.source !== 'huggingface') {
if (!this.currentVersion) {
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
return;
@@ -784,6 +980,77 @@ export class DownloadManager {
}
}
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths }) {
modalManager.closeModal('downloadModal');
this.loadingManager.restoreProgressBar();
const totalFiles = this.hfSelectedFiles.length;
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
try {
let completedDownloads = 0;
for (let i = 0; i < totalFiles; i++) {
const filename = this.hfSelectedFiles[i];
updateProgress(0, completedDownloads, filename);
this.loadingManager.setStatus(`Downloading ${filename}...`);
const downloadId = 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}`);
try {
await new Promise((resolve, reject) => {
ws.onopen = resolve;
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 === 'progress') {
const metrics = {
bytesDownloaded: data.bytes_downloaded,
totalBytes: data.total_bytes,
bytesPerSecond: data.bytes_per_second,
};
updateProgress(data.progress, snapshotCompleted, filename, metrics);
}
};
const response = await this.apiClient.downloadHfModel({
repo: this.hfRepoId,
filename,
revision: 'main',
modelRoot,
relativePath: targetFolder,
useDefaultPaths,
download_id: downloadId,
});
if (response?.success) {
completedDownloads++;
updateProgress(100, completedDownloads, filename);
}
} finally {
ws.close();
}
}
showToast('toast.loras.downloadCompleted', {}, 'success');
// Reload page data — model is already in scanner cache via backend
await resetAndReload(true);
return true;
} catch (error) {
console.error('Failed to download HF model:', error);
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
return false;
} finally {
this.loadingManager.hide();
}
}
updatePathSelectionUI() {
const manualSelection = document.getElementById('manualPathSelection');
@@ -812,13 +1079,19 @@ export class DownloadManager {
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
document.getElementById('batchPreviewStep').style.display = 'block';
const validCount = this.batchModels.filter(m => !m.error && m.selectedVersion).length;
const validCount = this.batchModels.filter(m => {
if (m.error) return false;
if (m.source === 'huggingface') return m.checked !== false;
return m.selectedVersion;
}).length;
document.getElementById('downloadModalTitle').textContent =
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
` (${validCount})`;
const list = document.getElementById('batchPreviewList');
list.innerHTML = this.batchModels.map((item, index) => {
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}">
@@ -837,6 +1110,30 @@ export class DownloadManager {
}
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
@@ -866,6 +1163,21 @@ export class DownloadManager {
`;
}).join('');
// Prepend select-all toolbar if there are HF items with checkboxes
if (hasHfItems) {
const allChecked = this.batchModels
.filter(m => m.source === 'huggingface' && !m.error)
.every(m => m.checked !== false);
itemsHtml = `
<div class="batch-preview-select-all">
<input type="checkbox" id="batchSelectAll" ${allChecked ? 'checked' : ''} />
<label for="batchSelectAll">${translate('modals.download.selectAll', {}, 'Select All')}</label>
</div>
` + itemsHtml;
}
list.innerHTML = itemsHtml;
list.onclick = (e) => {
const removeBtn = e.target.closest('.batch-preview-remove');
if (removeBtn) {
@@ -881,6 +1193,59 @@ export class DownloadManager {
}
};
// Checkbox handler for HF batch items
const checkboxes = list.querySelectorAll('.batch-preview-checkbox');
checkboxes.forEach(cb => {
cb.addEventListener('change', (e) => {
const idx = parseInt(e.target.dataset.index);
if (this.batchModels[idx]) {
this.batchModels[idx].checked = e.target.checked;
}
// 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);
}
});
});
// Select-all handler
const selectAll = document.getElementById('batchSelectAll');
if (selectAll) {
selectAll.addEventListener('change', (e) => {
const checked = e.target.checked;
const hfCheckboxes = list.querySelectorAll('.batch-preview-checkbox');
hfCheckboxes.forEach(cb => {
cb.checked = checked;
const idx = parseInt(cb.dataset.index);
if (this.batchModels[idx]) {
this.batchModels[idx].checked = checked;
}
});
// 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);
});
}
const nextBtn = document.getElementById('nextFromBatchBtn');
nextBtn.disabled = validCount === 0;
nextBtn.classList.toggle('disabled', validCount === 0);
@@ -903,7 +1268,12 @@ export class DownloadManager {
}
nextFromBatch() {
const validModels = this.batchModels.filter(m => !m.error && m.selectedVersion);
// For HF items, respect the checked flag; for CivitAI items, use selectedVersion
const validModels = this.batchModels.filter(m => {
if (m.error) return false;
if (m.source === 'huggingface') return m.checked !== false;
return m.selectedVersion;
});
if (validModels.length === 0) return;
this.proceedToLocation();
}
@@ -953,8 +1323,17 @@ export class DownloadManager {
targetFolder = this.folderTreeManager.getSelectedPath();
}
if (!this.isBatchMode) {
// Single-item download
if (this.source === 'huggingface') {
return this._downloadHfSingle({
modelRoot,
targetFolder,
useDefaultPaths,
});
}
const fileParams = this.selectedFile ? {
type: 'Model',
type: this.selectedFile.type || 'Model',
format: this.selectedFile.metadata?.format || 'SafeTensor',
size: this.selectedFile.metadata?.size || 'full',
fp: this.selectedFile.metadata?.fp,
@@ -974,7 +1353,13 @@ export class DownloadManager {
}
// Batch download mode
const downloadItems = this.batchModels.filter(m => !m.error && m.selectedVersion && !m.selectedVersion.existsLocally);
const downloadItems = this.batchModels.filter(m => {
if (m.error) return false;
if (!m.selectedVersion) return false;
// HF items have selectedVersion as a boolean marker + checked flag
if (m.source === 'huggingface') return m.checked !== false;
return !m.selectedVersion.existsLocally;
});
if (downloadItems.length === 0) {
showToast('toast.loras.downloadCompleted', {}, 'info');
modalManager.closeModal('downloadModal');
@@ -999,7 +1384,7 @@ export class DownloadManager {
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
const current = downloadItems[completedDownloads + failedDownloads];
const name = current?.selectedVersion?.name || `#${completedDownloads + failedDownloads + 1}`;
const name = current?.selectedVersion?.name || current?.displayName || current?.filename || `#${completedDownloads + failedDownloads + 1}`;
const metrics = {
bytesDownloaded: data.bytes_downloaded,
totalBytes: data.total_bytes,
@@ -1016,22 +1401,59 @@ export class DownloadManager {
for (let i = 0; i < downloadItems.length; i++) {
const item = downloadItems[i];
const ver = item.selectedVersion;
const name = ver?.name || `Model #${item.modelId}`;
const name = item.displayName || item.filename || (item.selectedVersion?.name || `Model #${item.modelId}`);
const isHf = item.source === 'huggingface';
updateProgress(0, completedDownloads, name);
loadingManager.setStatus(`${i + 1}/${downloadItems.length}: ${name}`);
try {
const response = await this.apiClient.downloadModel(
item.modelId,
ver.id,
modelRoot,
targetFolder,
useDefaultPaths,
batchDownloadId,
item.source
);
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 {
await new Promise((resolve, reject) => {
wsHf.onopen = resolve;
wsHf.onerror = reject;
});
const snapshotCompleted = completedDownloads;
wsHf.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'progress') {
const metrics = {
bytesDownloaded: data.bytes_downloaded,
totalBytes: data.total_bytes,
bytesPerSecond: data.bytes_per_second,
};
updateProgress(data.progress, snapshotCompleted, name, metrics);
}
};
response = await this.apiClient.downloadHfModel({
repo: item.repo,
filename: item.filename,
revision: item.revision || 'main',
modelRoot,
relativePath: targetFolder,
useDefaultPaths,
download_id: downloadId,
});
} finally {
wsHf.close();
}
} else {
response = await this.apiClient.downloadModel(
item.modelId,
item.selectedVersion.id,
modelRoot,
targetFolder,
useDefaultPaths,
batchDownloadId,
item.source
);
}
if (!response.success) {
failedDownloads++;
+9 -1
View File
@@ -27,6 +27,9 @@ export class SearchManager {
// Create clear button for search input
this.createClearButton();
// Keyboard shortcut cue element (static, exists in the HTML)
this.searchShortcutCue = document.getElementById('searchShortcutCue');
this.initEventListeners();
this.loadSearchPreferences();
this.setupKeyboardShortcuts();
@@ -163,8 +166,13 @@ export class SearchManager {
}
updateClearButtonVisibility() {
const hasText = this.searchInput.value.length > 0;
if (this.clearButton) {
this.clearButton.classList.toggle('visible', this.searchInput.value.length > 0);
this.clearButton.classList.toggle('visible', hasText);
}
// Toggle the keyboard shortcut cue: visible only when search is empty
if (this.searchShortcutCue) {
this.searchShortcutCue.classList.toggle('hidden', hasText);
}
}
+84 -21
View File
@@ -827,6 +827,23 @@ export class SettingsManager {
// Update API key status display (do NOT pre-fill the input)
this.updateApiKeyStatus();
this.updateLlmApiKeyStatus();
// AI Provider settings
const llmProviderSelect = document.getElementById('llmProvider');
if (llmProviderSelect) {
llmProviderSelect.value = state.global.settings.llm_provider || 'openai';
}
const llmApiBaseInput = document.getElementById('llmApiBase');
if (llmApiBaseInput) {
llmApiBaseInput.value = state.global.settings.llm_api_base || '';
}
const llmModelInput = document.getElementById('llmModel');
if (llmModelInput) {
llmModelInput.value = state.global.settings.llm_model || '';
}
const civitaiHostSelect = document.getElementById('civitaiHost');
if (civitaiHostSelect) {
@@ -905,15 +922,21 @@ export class SettingsManager {
showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false;
}
// Set group by model
const groupByModelCheckbox = document.getElementById('groupByModel');
if (groupByModelCheckbox) {
groupByModelCheckbox.checked = !!state.global.settings.group_by_model;
}
// Set model name display setting
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
if (modelNameDisplaySelect) {
modelNameDisplaySelect.value = state.global.settings.model_name_display || 'model_name';
}
const updateFlagStrategySelect = document.getElementById('updateFlagStrategy');
if (updateFlagStrategySelect) {
updateFlagStrategySelect.value = state.global.settings.update_flag_strategy || 'same_base';
const versionGroupingSelect = document.getElementById('versionGrouping');
if (versionGroupingSelect) {
versionGroupingSelect.value = state.global.settings.version_grouping || 'same_base';
}
// Set hide early access updates setting
@@ -2011,7 +2034,11 @@ export class SettingsManager {
}
}
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content') {
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content' || settingKey === 'group_by_model') {
// Save/restore sort preference when toggling group_by_model
if (settingKey === 'group_by_model' && window.pageControls?.onGroupByModelToggled) {
window.pageControls.onGroupByModelToggled(value);
}
this.reloadContent();
}
@@ -2060,7 +2087,7 @@ export class SettingsManager {
if (
settingKey === 'model_name_display'
|| settingKey === 'model_card_footer_action'
|| settingKey === 'update_flag_strategy'
|| settingKey === 'version_grouping'
|| settingKey === 'mature_blur_level'
) {
this.reloadContent();
@@ -2921,42 +2948,70 @@ export class SettingsManager {
}
}
editApiKey() {
const statusEl = document.getElementById('civitaiApiKeyStatus');
updateLlmApiKeyStatus() {
const hasKey = !!state.global.settings.llm_api_key;
const statusText = document.getElementById('llmApiKeyStatusText');
const actionBtn = document.getElementById('llmApiKeyActionBtn');
if (!statusText || !actionBtn) return;
if (hasKey) {
statusText.classList.remove('api-key-status--unconfigured');
statusText.classList.add('api-key-status--configured');
statusText.innerHTML = '<i class="fas fa-check-circle text-success"></i> '
+ translate('settings.aiProvider.apiKeyConfigured', {}, 'Configured');
actionBtn.textContent = translate('common.actions.change', {}, 'Change');
} else {
statusText.classList.remove('api-key-status--configured');
statusText.classList.add('api-key-status--unconfigured');
statusText.innerHTML = '<i class="fas fa-times-circle text-error"></i> '
+ translate('settings.aiProvider.apiKeyNotSet', {}, 'Not set');
actionBtn.textContent = translate('settings.aiProvider.apiKeySet', {}, 'Set up');
}
}
editApiKey(settingsKey = 'civitai_api_key', inputId = 'civitaiApiKey') {
const statusId = inputId + 'Status';
const editId = inputId + 'Edit';
const statusEl = document.getElementById(statusId);
if (statusEl) statusEl.classList.add('is-hidden');
const editContainer = document.getElementById('civitaiApiKeyEdit');
const editContainer = document.getElementById(editId);
if (editContainer) editContainer.classList.remove('is-hidden');
// Focus the input
const input = document.getElementById('civitaiApiKey');
const input = document.getElementById(inputId);
if (input) {
input.value = ''; // Never pre-fill the secret
setTimeout(() => input.focus(), 50);
}
}
cancelEditApiKey(silent) {
const editContainer = document.getElementById('civitaiApiKeyEdit');
cancelEditApiKey(silent, inputId = 'civitaiApiKey') {
const editId = inputId + 'Edit';
const statusId = inputId + 'Status';
const editContainer = document.getElementById(editId);
if (editContainer) editContainer.classList.add('is-hidden');
const statusContainer = document.getElementById('civitaiApiKeyStatus');
const statusContainer = document.getElementById(statusId);
if (statusContainer) statusContainer.classList.remove('is-hidden');
// Clear any typed value
const input = document.getElementById('civitaiApiKey');
const input = document.getElementById(inputId);
if (input) input.value = '';
if (!silent) {
this.updateApiKeyStatus();
if (inputId === 'civitaiApiKey') {
this.updateApiKeyStatus();
}
}
}
async saveApiKey() {
const input = document.getElementById('civitaiApiKey');
async saveApiKey(settingsKey = 'civitai_api_key', inputId = 'civitaiApiKey') {
const input = document.getElementById(inputId);
if (!input) return;
const value = input.value.trim();
try {
await this.saveSetting('civitai_api_key', value);
await this.saveSetting(settingsKey, value);
const labelName = settingsKey === 'civitai_api_key' ? 'CivitAI API Key' : 'LLM API Key';
showToast('toast.settings.settingsUpdated',
{ setting: 'CivitAI API Key' }, 'success');
{ setting: labelName }, 'success');
} catch (error) {
showToast('toast.settings.settingSaveFailed',
{ message: error.message }, 'error');
@@ -2964,9 +3019,13 @@ export class SettingsManager {
}
// Update the in-memory flag so the UI reflects the change
state.global.settings.civitai_api_key_set = !!value;
this.cancelEditApiKey(true);
this.updateApiKeyStatus();
if (settingsKey === 'civitai_api_key') {
state.global.settings.civitai_api_key_set = !!value;
}
this.cancelEditApiKey(true, inputId);
if (inputId === 'civitaiApiKey') {
this.updateApiKeyStatus();
}
}
toggleInputVisibility(button) {
@@ -3046,6 +3105,10 @@ export class SettingsManager {
const useNewLicenseIcons = state.global.settings.use_new_license_icons !== false;
document.body.classList.toggle('use-new-license-icons', useNewLicenseIcons);
// Apply group-by-model mode
const groupByModel = !!state.global.settings.group_by_model;
document.body.classList.toggle('group-by-model', groupByModel);
}
}
+8 -1
View File
@@ -57,9 +57,16 @@ export class DownloadManager {
base_model: this.importManager.recipeData.base_model || "",
loras: this.importManager.recipeData.loras || [],
gen_params: this.importManager.recipeData.gen_params || {},
raw_metadata: this.importManager.recipeData.raw_metadata || {}
raw_metadata: this.importManager.recipeData.raw_metadata || {},
};
// Preserve preview_nsfw_level from analysis so the saved
// recipe applies the correct NSFW blur on the preview image.
const nsfwLevel = this.importManager.recipeData.preview_nsfw_level;
if (nsfwLevel !== undefined && nsfwLevel !== null) {
completeMetadata.preview_nsfw_level = nsfwLevel;
}
const checkpointMetadata =
this.importManager.recipeData.checkpoint ||
this.importManager.recipeData.model ||
+17 -4
View File
@@ -4,12 +4,13 @@ import { ImportManager } from './managers/ImportManager.js';
import { BatchImportManager } from './managers/BatchImportManager.js';
import { RecipeModal } from './components/RecipeModal.js';
import { state, getCurrentPageState } from './state/index.js';
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { getStorageItem, setStorageItem, getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.js';
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
import { sidebarManager } from './components/SidebarManager.js';
import { initSortDropdown } from './components/controls/SortDropdown.js';
class RecipePageControls {
constructor() {
@@ -149,9 +150,10 @@ class RecipeManager {
_showCustomFilterIndicator() {
const indicator = document.getElementById('customFilterIndicator');
const textElement = document.getElementById('customFilterText');
if (!indicator) return;
const textElement = indicator.querySelector('.customFilterText');
if (!indicator || !textElement) return;
if (!textElement) return;
// Update text based on filter type
let filterText = '';
@@ -235,12 +237,18 @@ class RecipeManager {
}
initEventListeners() {
// Sort select
// Sort select — load saved preference, persist on change
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
const savedSort = getStorageItem('recipes_sort');
if (savedSort) {
this.pageState.sortBy = savedSort;
}
initSortDropdown(sortSelect);
sortSelect.value = this.pageState.sortBy || 'date:desc';
sortSelect.addEventListener('change', () => {
this.pageState.sortBy = sortSelect.value;
setStorageItem('recipes_sort', sortSelect.value);
refreshVirtualScroll();
});
}
@@ -250,6 +258,11 @@ class RecipeManager {
bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode());
}
const duplicatesButton = document.querySelector('[data-action="find-duplicates"]');
if (duplicatesButton) {
duplicatesButton.addEventListener('click', () => this.findDuplicateRecipes());
}
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
if (favoriteFilterBtn) {
favoriteFilterBtn.addEventListener('click', () => {
+6 -1
View File
@@ -44,7 +44,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
include_trigger_words: false,
compact_mode: false,
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
update_flag_strategy: 'same_base',
version_grouping: 'same_base',
hide_early_access_updates: false,
auto_organize_exclusions: [],
metadata_refresh_skip_paths: [],
@@ -54,6 +54,11 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
backup_retention_count: 5,
strip_lora_on_copy: false,
use_new_license_icons: true,
group_by_model: false,
llm_provider: 'openai',
llm_api_key: '',
llm_api_base: '',
llm_model: '',
});
export function createDefaultSettings() {

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