Compare commits

...

93 Commits

Author SHA1 Message Date
Will Miao
60175334b5 chore(release): bump version to v1.0.9 2026-05-28 21:46:46 +08:00
Will Miao
f65a01df00 feat(recipe): add bulk Repair Metadata for Selected operation to recipes page
Adds a new bulk operation in the recipes page that allows users to select
multiple recipes and repair their metadata in batch.

Backend:
- New POST /api/lm/recipes/repair-bulk endpoint accepting recipe_ids array
- repair_recipes_bulk handler iterates repair_recipe_by_id for each recipe
- Response includes per-recipe updated data for frontend card refresh

Frontend:
- Bulk context menu: new 'Repair Metadata for Selected' item in Metadata section
- BulkManager.repairSelectedRecipes() with loading/toast flow
- Uses VirtualScroller.updateSingleItem() per repaired recipe (no full reload)
- Visibility controlled via repairMetadata actionConfig flag

Locales:
- Added repairMetadata, repairBulkComplete, repairBulkSkipped, repairBulkFailed
- Translated across all 9 supported languages
2026-05-28 20:16:59 +08:00
Will Miao
430e24d70b fix(ui): hide skip-metadata-refresh bulk menu items for recipes 2026-05-28 19:11:49 +08:00
Will Miao
14f0c48fdd fix(recipe): detect and repair corrupted checkpoints in repair flow
Add corruption detection to _repair_single_recipe: if checkpoint.modelVersionId matches any LoRA's modelVersionId, the checkpoint is corrupted (a LoRA was saved as checkpoint). Clear the checkpoint and remove the matching LoRA entry, then let enrichment re-resolve the correct checkpoint from CivitAI metadata.

This fixes the retroactive repair path for the modelVersionIds[0] fallback bug.
2026-05-28 17:19:27 +08:00
Will Miao
34791c2ad7 fix(recipe): use resources type field to identify checkpoint instead of modelVersionIds[0]
When importing a CivitAI image as a recipe, modelVersionIds[0] was blindly used as the checkpoint version ID. This array mixes checkpoints and LoRAs without ordering guarantees, causing LoRAs to be saved as the recipe checkpoint.

Fix by:
1. Removing the modelVersionIds[0] fallback in _download_remote_media
2. Parsing resources entries with type:"model" as the checkpoint
3. Adding model type validation in populate_checkpoint_from_civitai

Also add 2 tests for the new behavior and fix 3 tests whose mocks lacked the required model.type field.
2026-05-28 15:46:38 +08:00
Will Miao
3f6824eef6 fix(example-images): exclude failed_models from check_pending_models pending count
Previously check_pending_models() only skipped models already in
processed_models, so models that had permanently failed (no CivitAI
images available, download errors) were forever reported as "pending".
This caused repeated auto-download cycles with no actual work to do.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:00:25 +08:00
Will Miao
3919dfa3f4 fix(metadata): suppress rate-limit propagation when model already confirmed deleted
When CivitAI returns 404 (ResourceNotFoundError) and a fallback provider
like CivArchive subsequently rate-limits, the ChainedMetadataProvider
now suppresses the RateLimitError instead of propagating it. Previously,
the rate-limit error would bubble up through _refresh_single_model and
cause the outer retry loop to re-process the same model repeatedly,
producing dozens of duplicate "Model X is no longer available" log
messages and wasting API quota.

The model is NOT permanently marked as ignored — its last_checked_at
timestamp is preserved, so it will be retried on the next refresh cycle
when the rate limit has cleared and CivArchive may still have the data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:56:22 +08:00
Will Miao
7124b5293f chore(settings): remove unused example_images config, add unet folder_paths example 2026-05-27 19:58:56 +08:00
Will Miao
d2a04f8993 fix(model-hash-index): clean up AutoV2 entry in remove_by_hash 2026-05-27 19:38:08 +08:00
pixelpaws
7027a7c270 Merge pull request #946 from 1756141021/fix/autov2-hash-matching
fix: match local LoRAs by AutoV2 hash when Civitai model is deleted
2026-05-27 19:20:31 +08:00
hein
0a1d7dfd4c fix: match local LoRAs by AutoV2 hash when Civitai model is deleted
When recipe metadata contains AutoV2 hashes (10-char short hash from
image metadata) and the Civitai API cannot resolve them to SHA256
(model deleted, API offline), the local hash index failed to match
because it only stored full SHA256 hashes.

AutoV2 is simply SHA256[:10], so we derive it automatically in
add_entry() — no extra file I/O or schema changes needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-27 14:15:01 +08:00
Will Miao
3962b1a96d fix(civitai): fall back to direct version fetch when modelVersions is empty for newly published models 2026-05-27 06:40:13 +08:00
Will Miao
8b856276bf fix(ui): escape HTML entities in parseMarkdown to prevent swallowed angle brackets 2026-05-27 06:40:13 +08:00
willmiao
c97c802956 docs: auto-update supporters list in README 2026-05-26 13:27:45 +00:00
Will Miao
24e2909627 chore(release): bump version to v1.0.8 2026-05-26 21:27:29 +08:00
Will Miao
b768f1368f fix(i18n): update aria2 annotation from experimental to recommended across all locales 2026-05-26 20:22:25 +08:00
Will Miao
37ccd29fc0 feat(modal): make version name editable in model modal (#931) 2026-05-26 20:16:35 +08:00
Will Miao
7416080cfb fix(civitai): retry transient server errors and cache version info to reduce 504 timeouts
CivitaiClient._make_request now retries 5xx/524/network errors up to 3 times with exponential backoff (1s, 2s) before giving up to the fallback provider chain.

get_model_version_info gains an in-memory OrderedDict cache (LRU, max 500 entries) so duplicate lookups of the same version ID within a single import/scan flow return instantly without a redundant API call.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-26 16:09:08 +08:00
Will Miao
26be187d42 fix(i18n): translate remaining loraSyntaxFormat TODO keys across all locales
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-26 06:15:57 +08:00
Will Miao
d7caa1fa47 fix(license): remove cascading commercial-use bit encoding, clarify Allow Selling label (#941)
- _resolve_commercial_bits() no longer has Sell-implies-Image
  cascading; each CommercialUse value sets only its own bit,
  matching CivitAI's modern array-format API.
- Keep filter tag label as 'Allow Selling' for brevity; add
  title/tooltip 'Allow selling generated images' on hover.
- Same tooltip treatment for 'No Credit Required'.
- Add i18n keys for both tooltips across all 10 locales.
2026-05-26 06:02:17 +08:00
Will Miao
2629fcce23 fix(doctor): add i18n translations for check items, action buttons, and labels
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-25 22:35:48 +08:00
Will Miao
438e7d07b9 fix(i18n): add missing conflictConfirm.detail and conflictConfirm.impact keys to all locales
These keys are referenced in DoctorManager.js via translate() calls but were never added to any locale file, causing the i18n regression test to fail.

Added to all 10 locales: en, zh-CN, zh-TW, ja, ko, ru, de, fr, es, he.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-25 22:25:13 +08:00
Will Miao
e9932ea870 feat(tags): add right-click context menu with copy for trigger word tags
- Add showTagContextMenu() with Copy option for all tags,
  plus Edit Group for multi-item group tags
- Attach contextmenu listener to simple tags
- Move group tag contextmenu outside items.length > 1 guard so
  single-child groups also get the context menu (bugfix)
- Clean up hanging context menu on re-render
2026-05-25 22:16:54 +08:00
Will Miao
5dd8b96422 fix(autocomplete): reactively refresh lora syntax format cache on settings change (#917)
The autocomplete module cached the lora_syntax_format value at module load
but never updated it when the setting changed, causing autocomplete to
always insert legacy A1111 format even when 'full path' was configured.

- Expose refreshLoraSyntaxFormat() to re-fetch the setting from the API
- Listen for cross-tab 'storage' events to react to settings saved in
  the standalone web UI
- Listen for 'visibilitychange' to refresh when the user switches back
  to the ComfyUI tab
- Wire SettingsManager.saveSetting() to set a localStorage key when
  lora_syntax_format changes, triggering the storage event
2026-05-25 22:03:56 +08:00
Will Miao
5e1cf68bbd fix(settings): sync loraSyntaxFormat select value from state on modal open (#917)
was missing the line to set the
select element's value from ,
causing the dropdown to always show the first option ("Full Path")
when reopening the settings modal, regardless of the persisted value.
Runtime behavior was unaffected since  reads from
the state directly.
2026-05-25 21:35:15 +08:00
Will Miao
1044fa3c83 feat(doctor): improve duplicate filename conflict UX with confirm modal, syntax-format nav, and i18n
- Remove [LoRAs] prefix noise from conflict detail display
- Limit inline conflict groups to 5, show remainder count
- Add 'Switch to Full Path Syntax' action in conflict card
- Add confirmation modal before resolving conflicts (shows rename strategy)
- Register resolveFilenameConflictsModal in ModalManager (fix no-op showModal)
- Switch to Interface section and add highlight animation on syntax-format nav
- Sync and translate conflictConfirm strings across all 10 locales
2026-05-25 21:25:35 +08:00
Will Miao
397892bb7f fix(recipe): treat transient server errors (524/5xx) as non-fatal in image info fetch
Extend _is_transient_server_error() check introduced in 15dfaed4 to
get_image_info(), so Cloudflare 524 and generic 5xx errors during
remote recipe import are logged as info instead of error and do not
produce scary tracebacks.

Same pattern as get_model_versions() - transient upstream failures
return None gracefully rather than being logged as errors.
2026-05-25 08:35:35 +08:00
Will Miao
f105500740 feat(doctor): suppress duplicate filename warnings when full path syntax is active (#917) 2026-05-22 22:35:06 +08:00
Will Miao
806555cf06 fix(test): update autocomplete test expectations for legacy lora syntax format (#917) 2026-05-22 21:56:38 +08:00
Will Miao
5cd7204101 fix(autocomplete): prevent blur-on-click race condition causing dropped selection (#939)
Add mousedown(e.preventDefault()) on dropdown items to prevent the textarea blur event from firing before click. Without this, the blur handler's formatAutocompleteTextOnBlur() modifies text with unmatched commas (e.g. "<lora:X:1>,search") and triggers hide() via suppressAutocompleteOnce, removing the item from the DOM before the click handler can execute.

Fixes #939
2026-05-22 21:50:26 +08:00
Will Miao
3b602a3698 feat(lora): add lora_syntax_format setting for syntax version toggle (#917)
Adds lora_syntax_format setting (full/legacy) that controls whether <lora:...> syntax uses relative paths (full) or filename only (legacy). Default is legacy for backward compatibility with A1111 convention. The full path format (<lora:relative/path/filename:strength>) enables lossless model resolution across subfolders.

Ultraworked with Sisyphus (https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-22 21:03:29 +08:00
Will Miao
15dfaed462 fix(api): treat transient server errors (524/5xx) as non-fatal in model updates (#935)
Teach CivitaiClient.get_model_versions() to recognise Cloudflare 524, generic
5xx, and connection-level errors as transient failures and return None
instead of raising RuntimeError, so a single upstream glitch does not
block the entire batch update or produce a scary traceback.

Also downgrade the generic except Exception log level in
ModelUpdateService._refresh_single_model() from error (with exc_info)
to warning (message only), since the full traceback is already logged
upstream in CivitaiClient.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-22 07:05:06 +08:00
Will Miao
0e51851025 fix(preview): stream video files manually to avoid Windows sendfile crash
aiohttp's FileResponse uses _sendfile_native on Windows (IOCP-based), which crashes with ov.getresult() when the client disconnects mid-transfer. This happens constantly when users scroll through a gallery of animated previews (video files like .mp4/.webm).

Detect video extensions and stream manually via StreamResponse + chunked reads instead, gracefully handling ConnectionResetError. Images continue using FileResponse (small files, sendfile works fine).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-21 09:12:10 +08:00
Will Miao
0d0f4defca feat(recipes): enable bulk Add Tags to Selected for recipes (#934)
- Set addTags: true in recipes bulk action config
- Add _saveRecipeTags() helper using recipe API endpoint
- Replace mode: saves tags array directly via PUT recipe/update
- Append mode: merges with existing tags from virtual scroller
- Shows bulk Add Tags modal & target menu item on recipes page
2026-05-20 23:14:38 +08:00
Will Miao
818fa34a48 fix(ui): auto-focus tag input and flush uncommitted text on save (#934)
- ModelModal (ModelTags.js): auto-focus input on entering tag edit mode
- ModelModal (ModelTags.js): flush uncommitted input text as tag on Save
- Bulk Add Tags (BulkManager.js): same two fixes
- RecipeModal already handled both cases correctly
2026-05-20 23:06:40 +08:00
Will Miao
78303b2a5e feat(ui): merge user tags into auto-tag badges and refresh on tag edit (#918)
- Layer 2 fallback: user tags overlapping with auto-tag categories
  (HIGH/LOW/I2V/T2V/TI2V/Lightning/Turbo) are merged into auto_tags,
  providing manual override when filename-based detection fails.
  Matching is case-insensitive so "high"/"High"/"HIGH" all work.
- Refresh on tag edit: save_metadata and add_tags handlers now return
  recalculated auto_tags in the response; the frontend passes them to
  VirtualScroller.updateSingleItem so badges update immediately without
  requiring a page reload.
- 8 new test cases for Layer 2 fallback and case-insensitive matching.
2026-05-20 22:48:44 +08:00
Will Miao
9ce56dd40c feat(lora): support relative paths in <lora:folder/name:strength> syntax (#917)
Autocomplete, copy/send-to-workflow, and recipe syntax now emit
<lora:folder/name:strength> instead of <lora:name:strength>, using
relative paths to disambiguate identically-named loras in different
subfolders without requiring file renames.

Backend: 3-tier hybrid resolution (path → bare → basename fallback)
across get_lora_info, get_lora_info_absolute, get_model_preview_url,
get_model_civitai_url, get_model_info_by_name, get_lora_metadata_by_filename,
and get_hash_by_filename. Also fix get_random_loras and get_cycler_list
to return path-prefixed names for randomizer/cycler consistency.

Frontend: autocomplete, copyLoraSyntax, handleSendToWorkflow emit
folder-prefixed syntax. extract_lora_name preserves relative paths.

Saved image metadata (<lora:...> in EXIF) intentionally keeps basename-only
for compatibility with A1111/Forge ecosystem.
2026-05-20 19:39:12 +08:00
Will Miao
33e5f3d85d fix(#933): compute SHA256 locally when CivitAI API returns empty hashes 2026-05-18 18:30:33 +08:00
Will Miao
031d5e4f40 fix(doctor): exclude checkpoints/embeddings from duplicate filename detection (#934)
Duplicate filename detection is only relevant for LoRAs, which use
basename-only syntax (<lora:name:strength>). Checkpoints and diffusion
models reference files via relative paths with extensions, so filename
conflicts there are false positives — there is no resolution ambiguity.

Both _log_duplicate_filename_summary() and DoctorHandler's
_check_filename_conflicts() now skip scanners with model_type != 'lora'.
2026-05-18 13:57:28 +08:00
willmiao
4ff5774e34 docs: auto-update supporters list in README 2026-05-17 12:40:26 +00:00
Will Miao
94e1a8ac7b chore(release): bump version to v1.0.7 2026-05-17 20:40:13 +08:00
Will Miao
cc20d3b992 feat(ui): auto-detect HIGH/LOW badges and auto-tag filters (#918)
- Backend auto-tag extraction service: detect HIGH/LOW (Wan-only), I2V/T2V/TI2V,
  Lightning/Turbo from filename, base_model, and CivitAI version name
- HIGH/LOW badge in card footer (inline before version name), color-coded:
  blue for HIGH, teal for LOW; abbreviated to H/L in medium/compact density
- Auto-tag filter panel (I2V, T2V, TI2V, Lightning, Turbo) with tri-state
  include/exclude filtering
- Full filter pipeline: FilterCriteria → ModelFilterSet → baseModelApi params
- AUTO_TAG_GROUPS exported for frontend use
- 19 unit tests for auto-tag extraction edge cases
2026-05-17 17:45:12 +08:00
Will Miao
a74cbe7aa2 fix(test): sync civitai bulk test with nsfw param 2026-05-16 22:15:55 +08:00
Will Miao
94edfaa190 fix(import): discover all resources from CivitAI modelVersionIds
CivitAI image API returns modelVersionIds at the root level of the
response (not inside meta), containing ALL model version IDs across
all resources (checkpoint + LoRAs). Two bugs prevented LoRAs from
being discovered:

1. _download_remote_media only extracted the first modelVersionId for
   enrichment, dropping the rest.
2. CivitAI API meta parsing only ran as an EXIF fallback, but most
   images have embedded EXIF metadata (prompt, steps, etc.), so the
   fallback was never triggered.
3. When civitai_meta_raw itself has a nested 'meta' key, unwrapping
   it stripped the injected modelVersionIds.

Also fixed gen_params merge: API gen_params now overlays EXIF at the
field level instead of full replacement, preserving EXIF-only fields
like detailed generation parameters.
2026-05-16 22:12:30 +08:00
Will Miao
31c54ff068 fix(civitai): add nsfw param to user-models and batch-ids queries (#930)
The CivitAI /api/v1/models endpoint defaults to filtering out NSFW
content when the nsfw query parameter is omitted. Both get_user_models()
and get_model_versions_bulk() hit this endpoint without passing nsfw=true,
causing models whose nsfwLevel doesn't include the PG bit to be silently
dropped from results.

Add nsfw=true to both call sites so all browsing levels are returned.
2026-05-16 20:15:03 +08:00
Will Miao
21872a8e9e fix(ui): default_active in group mode should not propagate to children; hide group badge/edit for single-child groups (#929) 2026-05-16 16:52:06 +08:00
Will Miao
612612f1c7 feat(ui): add Open Source URL action to recipe modal header, align header styles with model modal 2026-05-16 16:11:14 +08:00
Will Miao
ff240db5b1 chore: reduce remote recipe import log verbosity, demote detail fields to debug 2026-05-15 21:04:09 +08:00
Will Miao
bcfed4b874 feat(ui): use recipes terminology in bulk delete confirmation for recipes page
The bulk delete confirmation modal always displayed "models" in its
text (title, message, countMessage) regardless of the current page
type. On the recipes page this is misleading since users are managing
recipes, not models.

- Add bulkDeleteRecipes i18n keys to all 10 locale files
- Update showBulkDeleteModal() to detect currentPageType and use
  recipes-specific wording when on the recipes page
2026-05-15 20:55:02 +08:00
Will Miao
1352c6ecbe fix(recipes): fall back to Civitai API meta when EXIF is empty, enrich checkpoint in analyze_remote_image
- When downloaded Civitai image has no embedded EXIF, parse the
  already-fetched Civitai API meta (resources, hashes) directly
  instead of skipping parser altogether.
- Extract loras and model from parser output to fill metadata gaps
  when the primary import path doesn't provide them.
- Read modelVersionIds[0] as fallback when modelVersionId is None
  (Civitai API returns both but the singular form can be absent).
- Run RecipeEnricher in analyze_remote_image before returning, so
  the LM UI receives complete metadata including checkpoint with
  zero additional API calls (reuses the image_info already fetched).
2026-05-15 20:31:34 +08:00
Will Miao
30b01b8a92 fix(recipes): offload EXIF to thread pool, throttle concurrent imports, eliminate duplicate Civitai API call
- Wrap ExifUtils.extract_image_metadata() with asyncio.to_thread() in
  both import handlers and analysis_service to prevent Pillow/piexif
  from blocking ComfyUI's event loop during batch imports.
- Add asyncio.Semaphore(2) to import_remote_recipe and import_from_url
  endpoints to cap concurrent heavy work and prevent event loop starvation.
- Pre-fetch Civitai image_info during download and pass it to the recipe
  enricher, eliminating a redundant get_image_info() API round-trip.
2026-05-15 18:29:54 +08:00
Will Miao
a105cb322b fix(metadata): prune stale example-image entries when files are deleted on disk (#927) 2026-05-14 20:51:33 +08:00
Will Miao
3bf396d003 feat(recipes): add toggle to strip <lora:> tags when copying prompt/negative_prompt
Adds a compact inline toggle in the Generation Parameters section of the
Recipe Modal that, when enabled, strips <lora:name:weight> tags and
cleans up residual punctuation before copying to clipboard. The setting
persists across sessions via localStorage.
2026-05-13 11:47:02 +08:00
Will Miao
60cfb3b8e0 chore: add .sisyphus/ to .gitignore 2026-05-13 09:30:26 +08:00
Will Miao
6763abb83c fix(test): update test recipes to use source_path instead of source_url
Follow-up to 86118d06 which consolidated on source_path but missed updating these two tests.
2026-05-13 09:27:05 +08:00
Will Miao
5c53968caa refactor(download-history): rename mark_not_downloaded to mark_as_deleted
The method mark_not_downloaded() was misleading — it doesn't negate
'downloaded' history (the model was indeed downloaded before), but
rather sets is_deleted_override = 1 to indicate the version was
downloaded and subsequently deleted. This flag allows re-download when
the 'skip previously downloaded' setting is enabled.

Rename to mark_as_deleted() to accurately reflect its semantics.
2026-05-12 22:50:30 +08:00
Will Miao
b4f7dd75af fix(persistent-cache): persist scanner cache after model deletion
After deleting a model, the in-memory scanner cache was updated but the
SQLite persistent cache was not. On server restart, the stale persistent
cache caused check_model_version_exists() to return True, blocking
re-download with 'Model version already exists'.

Add _persist_current_cache() calls in both deletion paths:
- ModelLifecycleService.delete_model() (used by versions tab delete)
- delete_model_version handler in MiscHandlers
2026-05-12 22:50:10 +08:00
Will Miao
86118d0654 fix(recipes): persist source_path in SQLite cache and eliminate source_url redundancy
- Add source_path column to PersistentRecipeCache SQLite schema with
  migration for existing databases (ALTER TABLE ADD COLUMN)
- Backfill source_path from recipe JSON files on first startup after
  migration to avoid requiring manual cache rebuild
- Remove all source_url recipe field references (import_remote_recipe,
  import_from_url, check_image_exists, enrichment, batch_import)
  and consolidate on source_path as the single source of truth
- Add civitai.green to supported Civitai page hosts
- Register check-image-exists and import-from-url recipe endpoints
2026-05-12 20:39:09 +08:00
Will Miao
df1410535e fix(ui): remove redundant Quick Refresh from Refresh split button dropdown
The main Refresh button and Quick Refresh dropdown item both called refreshModels(false). Split button dropdowns should only contain alternative actions (Hick's Law). Dropdown now has only Rebuild Cache (fullRebuild=true). Removed from 2 templates, 2 JS files, 1 test fixture, and 10 locale files.
2026-05-12 07:50:54 +08:00
Will Miao
75f74d54d8 feat(bulk): reorganize context menu with sections and submenu for workflow actions
Group 15 flat menu items into 5 logical sections (Workflow, Metadata,
Attributes, Organize, Download) with section headers to reduce cognitive
load. Nest the three workflow-related actions (Append, Replace, Copy
Syntax) into a single "Send to Workflow" hover-triggered submenu.

Add submenu infrastructure to BaseContextMenu with mouseover/mouseout
boundary detection, 250ms close delay, and viewport-aware positioning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 21:06:47 +08:00
Will Miao
ab6100f596 feat(bulk): add "Download Example Images" to bulk select context menu (#923)
Allows downloading example images only for selected models instead of
the entire library. Reuses the existing /api/lm/force-download-example-images
endpoint which already accepts an array of model hashes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 18:05:00 +08:00
Will Miao
5d3ab3bbf8 feat(showcase): click-to-view full-size image/video in recipe and model modals (#926)
- Add MediaViewer overlay for full-size image/video display with prev/next
  navigation, direction keys, counter, and adjacent preloading
- Recipe modal: click preview image/video opens full-size viewer
- Model showcase: click any example image/video opens viewer with full
  gallery navigation; blurred NSFW content opens directly to clear view
- Use Map<Element, number> for DOM-index mapping instead of URL comparison
  to avoid index mismatch from lazy-loaded vs data-attribute URLs
2026-05-10 22:22:24 +08:00
Will Miao
d9dc0dba8d perf(startup): load extra model paths during Config init to avoid double symlink scan
Move extra folder path resolution from _initialize_services (app.on_startup)
into Config.__init__ via new _load_extra_paths_from_settings() method.
This eliminates a redundant second symlink scan and consolidates all
'Found roots' / 'Found extra roots' logs into one contiguous block
during custom node import, before the ComfyUI server starts.
2026-05-08 14:55:53 +08:00
Will Miao
3631c5eb10 chore: bump version to 1.0.6 2026-05-07 18:59:00 +08:00
Will Miao
6d5b4b7312 fix(test): update drag interaction test to match 454210a4's renderFunction→setValue change
Commit 454210a4 replaced renderFunction() with widget.value setter +
widget.callback() in endDrag, so the test assertion should verify
callback invocation instead of the removed renderSpy call.
2026-05-07 11:03:38 +08:00
Will Miao
7803bd542d feat(base-models): add Ernie, Ernie Turbo, Nucleus base model types (#922)
- Ernie & Anima: auto-fetched via CivitaiBaseModelService from Civitai API
- Ernie Turbo & Nucleus: pre-added as hardcoded constants (not yet in Civitai API)
- Added abbreviations (ERNI, ETRB, NUCL) and category entries across all layers
2026-05-07 10:49:01 +08:00
Will Miao
f0a86dbbc0 feat(bulk): add bulk favorite/unfavorite toggle with context-sensitive single menu item
Replaces two separate menu items with a single smart item that dynamically
switches between 'Set as Favorite' and 'Remove from Favorites' based on
whether all selected items are already favorited. Shows a count badge
'(3/5)' when only some items are favorited in a mixed selection.

Supports all model types (LoRA, Checkpoint, Embedding) and recipes via
existing per-item save/update API — no backend changes needed.
2026-05-07 09:51:23 +08:00
Will Miao
682e964f89 fix(usage-control): enrich usageControl from CivitAI by-hash API for all model types
The model-level API (GET /api/v1/models/{id}) does not include usageControl
on version entries, causing generation-only models to show as downloadable.

Backend changes:
- Add get_model_versions_by_hashes() to CivitaiClient (POST by-hash batch)
- Propagate through all provider classes including RateLimitRetryingProvider
- Add _enrich_version_entries() pipeline: extract SHA256 from files[].hashes,
  batch-call by-hash endpoint, inject usageControl+earlyAccessEndsAt in-place
- Wire enrichment into both bulk (_fetch_model_versions_bulk) and individual
  (_refresh_single_model) refresh paths
- Fix _build_record_from_remote dropping usage_control field
- Fix POST by-hash request format (plain JSON array, not {hashes:[...]} object)

Frontend changes:
- Fix disabled download button tooltip: wrap in <span> since HTML title
  attribute does not fire on disabled elements
2026-05-07 08:56:19 +08:00
Will Miao
908464bc0a docs: remove inline release notes from README (now maintained via GitHub Releases) 2026-05-06 22:40:06 +08:00
willmiao
0ffee3a854 docs: auto-update supporters list in README 2026-05-06 10:29:43 +00:00
Will Miao
8aa9739c44 data: refresh supporters from license server (739 supporters, includes Patreon data) 2026-05-06 18:29:21 +08:00
Will Miao
50739bbb43 fix(css): remove dead CSS properties causing Biome errors
- batch-import-modal.css: add generic font family fallback to Font Awesome
- card.css: remove dead margin-left overridden by shorthand margin: 0
- shared.css: remove duplicate position: absolute overridden by position: fixed
2026-05-06 09:33:15 +08:00
Will Miao
e849303763 fix(header): eliminate search input focus layout shift and reduce focus ring size
- Remove transform: translateY(-1px) that caused layout shift on focus
- Reduce box-shadow focus ring from 2px to 1px for subtler appearance
- Tone down drop-shadow from 4px/16px to 2px/8px (matches base state)
2026-05-06 09:33:04 +08:00
Will Miao
241b2e15d2 docs: update extension image URL 2026-05-05 22:26:40 +08:00
Will Miao
88da754504 docs: migrate wiki-images to wiki repo, remove stale docs
Moved wiki-images to the wiki repo (willmiao/ComfyUI-Lora-Manager.wiki). Updated README.md image reference to use wiki raw URL. Removed docs/LM-Extension-Wiki.md (superseded by wiki pages).
2026-05-05 22:20:19 +08:00
Will Miao
b4a706651f feat(delete-model-version): add GET endpoint to delete a model version by version ID 2026-05-05 21:25:08 +08:00
pixelpaws
ff7cc6d9bb Merge pull request #921 from 1756141021/fix/drag-strength-notify-setValue
fix: commit dragged strength through options.setValue at drag end
2026-05-05 16:20:48 +08:00
hein
454210a47c fix: commit dragged strength through options.setValue at drag end
During drag, handleStrengthDrag is called with updateWidget=false, which
mutates widgetValue in-place via parseLoraValue's direct array reference,
bypassing widget.value setter and options.setValue entirely.

endDrag only called renderFunction for a DOM refresh, but never flushed the
mutation through options.setValue. Any external observer that wraps
options.setValue (e.g. ComfyUI Mirror Panel's bidirectional sync) would
therefore never see the dragged value and would treat the widget as unchanged.

Fix: replace the explicit renderFunction call with widget.value = widget.value.
This flushes the in-place mutation through the setter (options.setValue), which
re-renders the DOM internally AND notifies all setValue wrappers. Also fire
widget.callback for parity with the updateWidget=true path in handleStrengthDrag.

Applies the same fix to initHeaderDrag (proportional all-LoRA header drag).
2026-05-04 22:40:30 +08:00
Will Miao
2d7c404ebb fix(recipes): preserve scroll position on filter, search, and folder-driven reloads
Five entry points that trigger recipe page reloads were not passing
preserveScroll: true, causing the page to snap back to top after
filtering, searching, or navigating folders — especially painful with
hundreds of recipes.

- RecipePageControls.resetAndReload() → refreshVirtualScroll() now
  passes { preserveScroll: true } (sidebar folder clicks/drag moves)
- FilterManager applyFilters/clearAllFilters → loadRecipes(true)
  changed to loadRecipes({ preserveScroll: true })
- SearchManager performSearch → loadRecipes(true) changed to
  loadRecipes({ preserveScroll: true })
- SettingsManager reloadContent → loadRecipes() changed to
  loadRecipes({ preserveScroll: true })

The normalizeLoadRecipesOptions boolean path always forces
preserveScroll: false — the object form is required to pass it.
2026-05-04 20:26:13 +08:00
Will Miao
e23d803ecf fix(layout): ensure refresh split-button dropdown renders above breadcrumb nav 2026-05-03 18:14:54 +08:00
Will Miao
0cc640cfaa fix(recipe): support ComfyUI-Easy-Use nodes in runtime metadata extraction (#920)
- Add EasyComfyLoaderExtractor for comfyLoader (easy comfyLoader):
  extracts checkpoint, optional_lora_stack as LoRA apply node,
  prompt text, clip_skip, and latent dimensions
- Add EasyPreSamplingExtractor for samplerSettings (easy preSampling):
  extracts steps, cfg, sampler_name, scheduler, denoise, seed
- Add EasySeedExtractor for easySeed
- Fix clip_skip hardcoded to '1' — now searched from SAMPLING metadata
- Lora Stacker nodes intentionally excluded from extraction to
  prevent double-counting; LoRAs only recorded at apply nodes
2026-05-02 23:21:51 +08:00
Will Miao
2ac0eb0f9d fix(wanvideo): resolve lora path resolution and name truncation for extra folder paths
- Use get_lora_info_absolute to obtain correct absolute paths for loras
  in LM extra folder paths, instead of folder_paths.get_full_path which
  only searches ComfyUI's standard loras directories (returned None)
- Fix name field truncation: str.split('.')[0] stopped at the first dot,
  replaced with os.path.splitext to only strip the file extension
- Add _relpath_within_loras helper to preserve subdirectory info in the
  name field, matching WanVideoWrapper's os.path.splitext(lora)[0] format
2026-05-02 14:55:12 +08:00
Will Miao
f028625ce9 feat(check-models-exist): add batch endpoint for checking multiple model IDs
New endpoint: GET /api/lm/check-models-exist?modelIds=1,2,3,...

Accepts comma-separated modelIds, returns a results array with one
entry per modelId. Uses a single scanner lookup batch - three
service-registry calls total, regardless of model count. Skips
history checks entirely (same rationale as the singleton endpoint:
when models exist locally, history is redundant).

Expected: reduces 231 HTTP round-trips to 1 for the browser
extension's model-card indicator flow. Combined with the prior
SQLite-connection and history-skip fixes, total wall-clock time
for a 175K-lora user's page load drops from ~9.4s to <10ms.
2026-05-02 13:43:53 +08:00
Will Miao
06acc7f576 fix(trigger-word-toggle): default group children to active regardless of default_active 2026-05-02 13:33:42 +08:00
Will Miao
d324b57274 perf(check-model-exists): eliminate SQLite connection-per-query overhead and skip redundant history checks
Root cause: 231 concurrent /check-model-exists requests on 175K-lora library
caused ~9.4s wall clock time. The bottleneck was two-fold:

1. DownloadedVersionHistoryService opened a new sqlite3.connect() for every
   query under asyncio.Lock. With a large WAL from 175K entries, each
   connect() took ~8ms. Serialized by the lock across 231 requests, the
   230th request waited ~1848ms just for lock acquisition.

2. check_model_exists always queried download history even when the model
   was found locally. The history result (hasBeenDownloaded /
   downloadedVersionIds) is only used by the UI when the model is NOT
   found locally; when found, the 'in library' indicator takes priority.

Changes:
- downloaded_version_history_service.py: added persistent _get_conn() that
  creates the SQLite connection once and reuses it across all queries
- misc_handlers.py: early-return from check_model_exists when the model
  exists locally, bypassing the history service entirely (lock skipped)

Expected: per-request wait time drops from ~1912ms to <3ms, wall clock
from ~9.4s to <0.3s for the 175K-lora user's 231-card page.
2026-05-02 13:31:20 +08:00
Will Miao
502b7eab31 fix(layout): correct breadcrumb sticky behavior and controls wrapping overflow
- Extract breadcrumb from controls template into sibling component
- Fix breadcrumb sticky positioning (top: 0, z-index: calc(--z-header - 1))
- Add 1500px breakpoint to wrap controls-right and prevent overflow
- Adjust breadcrumb padding-bottom to cover controls-right area when sticky
2026-05-01 22:53:40 +08:00
Will Miao
be75ad930e feat(layout): implement responsive edge-to-edge card grid with density-aware column calculation
- Add dynamic column calculation based on container width and min card width
- Prevent tiny cards on narrow windows by respecting density-based minimums:
  - Default: 240px, Medium: 200px, Compact: 170px
- Fix edge-to-edge layout with proper CSS selector (.virtual-scroll-item.model-card)
- Add hamburger menu for mobile/small screens with proper translations
- Update all locale files with 'common.actions.menu' key

Fixes: Cards becoming too small/overlapping on narrow window widths (e.g., 1156px)
Changes: 15 files, +569/-114 lines
2026-05-01 21:34:31 +08:00
Will Miao
763c4f4dad feat(usage-control): add support for Civitai usageControl field
Handle models that are only available for on-site generation (usageControl:
"Generation" or "InternalGeneration") rather than downloadable.

Backend changes:
- Add usage_control field to ModelVersionRecord dataclass
- Extract usageControl from Civitai API responses
- Filter non-downloadable versions from update availability checks
- Add database schema migration for usage_control column
- Include usageControl in version response JSON

Frontend changes:
- Add isDownloadAllowed() helper function
- Show disabled download button for non-downloadable versions
- Add "On-Site Only" badge for restricted versions
- Update resolveUpdateAvailability() to filter non-downloadable versions
- Add CSS styling for disabled action button

Internationalization:
- Add translations for onSiteOnly badge and downloadNotAllowedTooltip
- Complete translations for all 10 supported languages
2026-05-01 13:10:15 +08:00
Will Miao
d32c492bdb feat(scripts): add legacy metadata migration tool
Add script to migrate metadata from legacy sidecar JSON files to
LoRA Manager's metadata.json format.

Features:
- Auto-discovers model folders from settings.json
- Supports LoRA and Checkpoint model types
- Migrates activation text, preferred weight (LoRA only), and notes
- Dry-run mode for safe preview
- Idempotent migration (won't duplicate existing data)
2026-05-01 08:56:00 +08:00
Will Miao
5dcfde36ea feat(doctor): add duplicate filename conflict detection and one-click resolution
Detects when multiple model files share the same basename (causing
ambiguity in LoRA resolution), logs warnings during scanning, and
provides a "Resolve Conflicts" button in the Doctor panel. Resolution
renames duplicates with hash-prefixed unique filenames, migrates all
sidecar and preview files, and updates the cache and frontend scroller
in-place so the model modal immediately reflects the new filename.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 15:21:26 +08:00
Will Miao
1d035361a4 fix(download): accept Diffusion Model file type when selecting primary file from CivitAI metadata
CivitAI returns file type "Diffusion Model" for checkpoint files (e.g., Anima
models), but the file selection logic only accepted "Model" and "Negative",
causing "No suitable file found in metadata" errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 11:54:14 +08:00
Will Miao
25605c5e78 feat(ui): add setting to toggle version name display on model cards (#916) 2026-04-29 20:04:40 +08:00
Will Miao
f3268a6179 fix(autocomplete): prevent migrateWidgetsValues from dropping text widget values (#915)
shouldBypassAutocompleteWidgetMigration only matched inputs by widget name,
but ComfyUI's migrateWidgetsValues also matches forceInput inputs (like "seed").
This discrepancy meant the bypass never triggered for TextLM/PromptLM nodes,
causing migrateWidgetsValues to filter out real widget values by incorrectly
mapping forceInput flags onto saved autocomplete values.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:44:08 +08:00
156 changed files with 8077 additions and 1621 deletions

2
.gitignore vendored
View File

@@ -15,7 +15,9 @@ model_cache/
# agent
.opencode/
.claude/
.sisyphus/
.codex
.omo
# Vue widgets development cache (but keep build output)
vue-widgets/node_modules/

136
README.md

File diff suppressed because one or more lines are too long

View File

@@ -12,43 +12,36 @@
"2018cfh",
"W+K+White",
"wackop",
"Takkan",
"Phil",
"Carl G.",
"Arlecchino Shion",
"Charles Blakemore",
"Rob Williams",
"$MetaSamsara",
"itismyelement",
"onesecondinosaur",
"stone9k",
"Rosenthal",
"Francisco Tatis",
"Andrew Wilson",
"Greybush",
"Gooohokrbe",
"Ricky Carter",
"JongWon Han",
"OldBones",
"VantAI",
"runte3221",
"FreelancerZ",
"Edgar Tejeda",
"Liam MacDougal",
"Fraser Cross",
"Polymorphic Indeterminate",
"Birdy",
"Marc Whiffen",
"Jorge Hussni",
"Kiba",
"Skalabananen",
"Birdy",
"Kiba",
"Mozzel",
"itismyelement",
"Gingko Biloba",
"Reno Lam",
"onesecondinosaur",
"sig",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"J\\B/ 8r0wns0n",
"Snaggwort",
"Arlecchino Shion",
"Charles Blakemore",
"Rob Williams",
"Takkan",
"ClockDaemon",
"KD",
"Omnidex",
@@ -56,20 +49,20 @@
"Release Cabrakan",
"Tobi_Swagg",
"SG",
"carozzz",
"James Dooley",
"zenbound",
"Buzzard",
"jmack",
"Andrew Wilson",
"Greybush",
"Mark Corneglio",
"SarcasticHashtag",
"Cosmosis",
"iamresist",
"RedrockVP",
"Wolffen",
"FloPro4Sho",
"Ricky Carter",
"James Todd",
"Steven Pfeiffer",
"VantAI",
"Tim",
"Lisster",
"Michael Wong",
@@ -77,8 +70,6 @@
"Tom Corrigan",
"JackieWang",
"fnkylove",
"Julian V",
"Steven Owens",
"Yushio",
"Vik71it",
"Echo",
@@ -86,18 +77,16 @@
"Robert Stacey",
"PM",
"Todd Keck",
"Mozzel",
"Gingko Biloba",
"Edgar Tejeda",
"Jorge Hussni",
"Liam MacDougal",
"Sterilized",
"BadassArabianMofo",
"Pascal Dahle",
"quarz",
"Greg",
"Penfore",
"JSST",
"esthe",
"Snaggwort",
"lmsupporter",
"IamAyam",
"wfpearl",
"Baekdoosixt",
"Jonathan Ross",
@@ -110,18 +99,19 @@
"contrite831",
"Alex",
"bh",
"confiscated Zyra",
"carozzz",
"Marlon Daniels",
"Starkselle",
"Aaron Bleuer",
"LacesOut!",
"greebles",
"Adam Shaw",
"Tee Gee",
"Anthony Rizzo",
"tarek helmi",
"M Postkasse",
"ASLPro3D",
"Gooohokrbe",
"RedrockVP",
"Wicked Choices by ASLPro3D",
"OldBones",
"Jacob Hoehler",
"FinalyFree",
"Weasyl",
@@ -129,7 +119,6 @@
"Johnny",
"Cory Paza",
"Tak",
"Gonzalo Andre Allendes Lopez",
"Zach Gonser",
"Big Red",
"whudunit",
@@ -138,149 +127,124 @@
"Philip Hempel",
"corde",
"Nick Walker",
"lh qwe",
"Bishoujoker",
"conner",
"aai",
"Briton Heilbrun",
"Tori",
"wildnut",
"Princess Bright Eyes",
"AbstractAss",
"Felipe dos Santos",
"ViperC",
"jean jahren",
"Aleksander Wujczyk",
"AM Kuro",
"Markus",
"S Sang",
"Pascal Dahle",
"Penfore",
"Sangheili460",
"MagnaInsomnia",
"Karl P.",
"Akira_HentAI",
"MagnaInsomnia",
"Gordon Cole",
"yuxz69",
"Douglas Gaspar",
"AlexDuKaNa",
"George",
"AbstractAss",
"andrew.tappan",
"dw",
"N/A",
"The Spawn",
"Phil",
"graysock",
"Greenmoustache",
"zounic",
"fancypants",
"Eldithor",
"Digital",
"JaxMax",
"takyamtom",
"奚明 刘",
"Jwk0205",
"Bro Xie",
"준희 김",
"batblue",
"carey6409",
"Olive",
"太郎 ゲーム",
"Some Guy Named Barry",
"Max Marklund",
"Tomohiro Baba",
"David Ortega",
"Cosmosis",
"AELOX",
"Nicfit23",
"Noora",
"FloPro4Sho",
"wamekukyouzin",
"drum matthieu",
"Dogmaster",
"Matt Wenzel",
"Mattssn",
"Lex Song",
"John Saveas",
"Christopher Michel",
"Gonzalo Andre Allendes Lopez",
"Serge Bekenkamp",
"Jimmy Ledbetter",
"LeoZero",
"Antonio Pontes",
"ApathyJones",
"Julian V",
"Steven Owens",
"nahinahi9",
"Dustin Chen",
"dan",
"Yaboi",
"Mouthlessman",
"Steam Steam",
"Damon Cunliffe",
"CryptoTraderJK",
"Davaitamin",
"otaku fra",
"ViperC",
"Ran C",
"tedcor",
"Fotek Design",
"MiraiKuriyamaSy",
"yuxz69",
"Adam Taylor",
"Weird_With_A_Beard",
"MadSpin",
"esthe",
"Pozadine1",
"Qarob",
"AIGooner",
"inbijiburu",
"Luc",
"ProtonPrince",
"DiffDuck",
"elu3199",
"Nick “Loadstone” D",
"Hasturkun",
"Jon Sandman",
"Ubivis",
"CloudValley",
"thesoftwaredruid",
"wundershark",
"mr_dinosaur",
"linnfrey",
"Gamalonia",
"Vir",
"Pkrsky",
"IamAyam",
"skaterb949",
"Joboshy",
"Bohemian Corporal",
"Dan",
"Josef Lanzl",
"Seth Christensen",
"Griffin Dahlberg",
"Draven T",
"confiscated Zyra",
"yer fey",
"Error_Rule34_Not_found",
"Gerald Welly",
"Roslynd",
"Geolog",
"Tee Gee",
"jinxedx",
"tarek helmi",
"Neco28",
"Aquatic Coffee",
"Max Marklund",
"David Ortega",
"Dankin",
"ethanfel",
"Cristian Vazquez",
"Frank Nitty",
"Magic Noob",
"Focuschannel",
"Pronredn",
"DougPeterson",
"Jeff",
"Bruce",
"lh qwe",
"Kevin John Duck",
"Anthony Faxlandez",
"conner",
"Kevin Christopher",
"Ouro Boros",
"Blackfish95",
"dd",
"Princess Bright Eyes",
"Paul Kroll",
"MiraiKuriyamaSy",
"semicolon drainpipe",
"Thesharingbrother",
"Felipe dos Santos",
"Bas Imagineer",
"Pat Hen",
"John Statham",
"ResidentDeviant",
"Nihongasuki",
"JC",
"Prompt Pirate",
"uwutismxd",
"Douglas Gaspar",
"AlexDuKaNa",
"George",
"dw",
"decoy",
"thesoftwaredruid",
"wundershark",
"mr_dinosaur",
"Tyrswood",
"Ray Wing",
"Ranzitho",
@@ -298,80 +262,66 @@
"Piccio08",
"kumakichi",
"cppbel",
"starbugx",
"Moon Knight",
"몽타주",
"Kland",
"zenobeus",
"Jackthemind",
"ryoma",
"Stryker",
"raf8osz",
"ElitaSSJ4",
"blikkies",
"Chris",
"奚明 刘",
"Brian M",
"Josef Lanzl",
"Nerezza",
"sanborondon",
"준희 김",
"Taylor Funk",
"aezin",
"Thought2Form",
"jcay015",
"Gerald Welly",
"Kevin Picco",
"Erik Lopez",
"Shock Shockor",
"Mateo Curić",
"Goldwaters",
"Zude",
"Geolog",
"Eris3D",
"Tomohiro Baba",
"m",
"Noora",
"Pierce McBride",
"Joshua Gray",
"Kyler",
"Mattssn",
"Mikko Hemilä",
"aRtFuL_DodGeR",
"Jamie Ogletree",
"a _",
"James Coleman",
"CrimsonDX",
"Martial",
"battu",
"Emil Andersson",
"Ouro Boros",
"Chad Idk",
"DarkSunset",
"Billy Gladky",
"Steam Steam",
"CryptoTraderJK",
"Yuji Kaneko",
"Probis",
"Davaitamin",
"Dušan Ryban",
"ItsGeneralButtNaked",
"Jordan Shaw",
"Rops Alot",
"tedcor",
"Sam",
"Fotek Design",
"sjon kreutz",
"Nimess",
"SRDB",
"Ace Ventura",
"g unit",
"Youguang",
"MadSpin",
"Metryman55",
"andrewzpong",
"FrxzenSnxw",
"BossGame",
"lrdchs",
"inbijiburu",
"Nick “Loadstone” D",
"Gamalonia",
"momokai",
"starbugx",
"Moon Knight",
"몽타주",
"Kland",
"Hailshem",
"kudari",
"Naomi Hale Danchi",
"dc7431",
"ken",
"Inversity",
"AIVORY3D",
"epicgamer0020690",
"Joshua Porrata",
"keemun",
"SuBu",
"RedPIXel",
"Kevinj",
"Wind",
"Nexus",
"Ramneek“Guy”Ashok",
@@ -383,82 +333,62 @@
"JohnDoe42054",
"BillyHill",
"emyth",
"chriphost",
"KitKatM",
"socrasteeze",
"ResidentDeviant",
"Vir",
"gzmzmvp",
"Welkor",
"John Martin",
"Richard",
"Andrew",
"Robert Wegemund",
"Littlehuggy",
"moranqianlong",
"Gregory Kozhemiak",
"Draven T",
"mrjuan",
"Brian Buie",
"Sadlip",
"Haru Yotu",
"Eric Whitney",
"Joey Callahan",
"Aquatic Coffee",
"Ivan Tadic",
"Mike Simone",
"ethanfel",
"Joshua Gray",
"Morgandel",
"Kyron Mahan",
"Matura Arbeit",
"Focuschannel",
"Noah",
"Jacob McDaniel",
"X",
"Sloan Steddy",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Temikus",
"Artokun",
"Michael Taylor",
"SendingRavens",
"Derek Baker",
"Anthony Faxlandez",
"battu",
"Michael Anthony Scott",
"Atilla Berke Pekduyar",
"Michael Docherty",
"Nathan",
"Decx _",
"Paul Hartsuyker",
"elitassj",
"Jacob Winter",
"Distortik",
"David",
"Meilo",
"Pen Bouryoung",
"Pat Hen",
"Jordan Shaw",
"四糸凜音",
"shinonomeiro",
"Snille",
"MaartenAlbers",
"khanh duy",
"xybrightsummer",
"jreedatchison",
"PhilW",
"Tree Tagger",
"Janik",
"Nihongasuki",
"JC",
"Prompt Pirate",
"uwutismxd",
"FrxzenSnxw",
"zenobeus",
"Crocket",
"Cruel",
"MRBlack",
"Mitchell Robson",
"Kiyoe",
"humptynutz",
"michael.isaza",
"Kalnei",
"Whitepinetrader",
"Jackthemind",
"chriphost",
"KitKatM",
"ryoma",
"socrasteeze",
"OrganicArtifact",
"Scott",
"Stryker",
"ResidentDeviant",
"MudkipMedkitz",
"deanbrian",
"POPPIN",
"Alex Wortman",
"Cody",
"Raku",
"smart.edge5178",
"emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
@@ -466,76 +396,214 @@
"Menard",
"Skyfire83",
"Adam Rinehart",
"D",
"Pitpe11",
"TheD1rtyD03",
"moonpetal",
"SomeDude",
"g9p0o",
"nanana",
"TheHolySheep",
"raf8osz",
"Monte Won",
"SpringBootisTrash",
"carsten",
"ikok",
"Buecyb99",
"4IXplr0r3r",
"dfklsjfkljslfjd",
"hayden",
"ahoystan",
"Leland Saunders",
"ElitaSSJ4",
"Wolfe7D1",
"Ink Temptation",
"Bob Barker",
"edk",
"Kalli Core",
"Aeternyx",
"blikkies",
"Chris",
"elleshar666",
"YOU SINWOO",
"ja s",
"Doug Mason",
"Shock Shockor",
"ACTUALLY_the_Real_Willem_Dafoe",
"Goldwaters",
"Kauffy",
"Jeremy Townsend",
"EpicElric",
"Sean voets",
"Owen Gwosdz",
"Zude",
"John J Linehan",
"Kyler",
"Elliot E",
"Thomas Wanner",
"Theerat Jiramate",
"Edward Kennedy",
"Justin Blaylock",
"Devil Lude",
"Nick Kage",
"kevin stoddard",
"Jack Dole",
"aRtFuL_DodGeR",
"Vane Holzer",
"psytrax",
"Ezokewn",
"hexxish",
"CptNeo",
"notedfakes",
"Maso",
"Eric Ketchum",
"Nathan",
"Billy Gladky",
"NICHOLAS BAXLEY",
"Michael Scott",
"Kevin Wallace",
"Matheus Couto",
"Probis",
"Ed Wang",
"Wes Sims",
"ItsGeneralButtNaked",
"SRDB",
"g unit",
"Distortik",
"Filippo Ferrari",
"Youguang",
"Saya",
"andrewzpong",
"BossGame",
"lrdchs",
"Tree Tagger",
"Inversity",
"AIVORY3D",
"Kevinj",
"Mitchell Robson",
"Whitepinetrader",
"POPPIN",
"Ginnie",
"Raku",
"emadsultan",
"Pkrsky",
"nanana",
"FeralOpticsAI",
"Pavlaki",
"Doug+Rintoul",
"Noor",
"Yorunai",
"quantenmecha",
"Jason+Nash",
"BillyBoy84",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"lrdchs2",
"Duk3+Rand0m",
"Nathen+Choi",
"T",
"LarsesFPC",
"cocona",
"Buecyb99",
"Welkor",
"David Schenck",
"John Martin",
"Ink Temptation",
"moranqianlong",
"Kalli Core",
"Time Valentine",
"Михал Михалыч",
"Matt",
"Frogmilk",
"SPJ",
"Kyron Mahan",
"Bryan Rutkowski",
"Nick Kage",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Cyrus Fett",
"Ezokewn",
"SendingRavens",
"Xenon Xue",
"JackJohnnyJim",
"Edward Ten Eyck",
"Michael Docherty",
"Paul Hartsuyker",
"Henrique Faiolli",
"elitassj",
"Solixer",
"Jacob Winter",
"Ryan Presley Ng",
"jinksta187",
"Donor4115",
"Manu Thetug",
"Karlanx",
"Lyavph",
"David",
"Meilo",
"operationancut",
"shinonomeiro",
"Snille",
"MaartenAlbers",
"khanh duy",
"xybrightsummer",
"jreedatchison",
"PhilW",
"Marcus thronico",
"Janik",
"Cruel",
"MRBlack",
"Kiyoe",
"humptynutz",
"michael.isaza",
"Kalnei",
"Scott",
"Muratoraccio",
"D",
"YassineKhaled",
"Y",
"MatteKey",
"Flob",
"ShiroSenpai",
"Inkognito",
"G",
"Tan+Huynh",
"D",
"Dark_Pest",
"Alex",
"Jacky+Ho",
"Karru",
"ghoulars",
"ChaChanoKo",
"null",
"Beau",
"redcarrot",
"powerbot99",
"Fthehappy",
"rsamerica",
"sfasdfasfdsa",
"Alan+Cano",
"generic404",
"abattoirblues",
"zounik",
"4IXplr0r3r",
"hayden",
"ahoystan",
"Bob Barker",
"edk",
"JBsuede",
"Christian Schäfer",
"りん あめ",
"ja s",
"Doug Mason",
"Jeremy Townsend",
"Dave Abraham",
"Joaquin Hierrezuelo",
"Locrospiel",
"Sean voets",
"Owen Gwosdz",
"Jarrid Lee",
"Kor",
"Joseph Hanson",
"John Rednoulf",
"Boba Smith",
"Devil Lude",
"David Murcko",
"Jack Dole",
"max blo",
"Sauv",
"Steven",
"CptNeo",
"TenaciousD",
"Dmitry Ryzhov",
"Khánh Đặng",
"Maso",
"Eric Ketchum",
"Kevin Wallace",
"Jimmy Borup",
"ChicRic",
"mercur",
"J C",
"Ed Wang",
"Ryan Presley Ng",
"Wes Sims",
"Donor4115",
"Pete Pain",
"RHopkirk",
"Andrew Wilkinson",
"Yavizu3d",
"Maxim",
"Yves Poezevara",
"Teriak47",
"Just me",
"Raf Stahelin",
"Вячеслав Маринин",
"Lyavph",
"Filippo Ferrari",
"Cola Matthew",
"OniNoKen",
"Iain Wisely",
@@ -555,7 +623,6 @@
"pixl",
"Robin",
"chahknoir",
"Marcus thronico",
"nd",
"keno94d",
"James Melzer",
@@ -569,6 +636,7 @@
"Captain_Swag",
"obkircher",
"gwyar",
"ResidentDeviant",
"D",
"edgecase",
"Neoxena",
@@ -576,98 +644,137 @@
"dg",
"Maarten Harms",
"Israel",
"Muratoraccio",
"SelfishMedic",
"Ginnie",
"adderleighn",
"EnragedAntelope",
"Alan+Cano",
"FeralOpticsAI",
"Pavlaki",
"generic404",
"Kachac",
"tyrant2811",
"Kevin",
"Rune+Osnes",
"jcx29",
"cloudghost",
"Yongkwan+Lee",
"PoorStudent",
"lucites",
"Alex+Zaw",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"Drizzly",
"Sildoren",
"Darvidous",
"Seon+Song",
"2turbo",
"balut+omelette",
"Nebuleux",
"Dmitry+Viznesenskiy",
"Tanjin90",
"Somebody",
"sternenkrieger",
"eriick",
"Join+Chun",
"Pascalou",
"lighthawke",
"Terraformer",
"GDS+DEV",
"4rt+r3d",
"low9",
"Winged",
"you+halo9",
"Somebody",
"Somebody",
"Crescent~San",
"AiGirlTS",
"datasl4ve",
"Somebody",
"koopa990",
"The+Forgetful+Dev",
"Mateusz+Kosela",
"Doug+Rintoul",
"Noor",
"Yorunai",
"Bula",
"quantenmecha",
"abattoirblues",
"Jason+Nash",
"BillyBoy84",
"DarkRoast",
"zounik",
"letzte",
"Nasty+Hobbit",
"SgtFluffles",
"lrdchs2",
"Duk3+Rand0m",
"KUJYAKU",
"NathenChoi",
"Thomas+Reck",
"Larses",
"cocona",
"Coeur+de+cochon",
"David Schenck",
"Obsidian.Studios",
"han b",
"Zomba Mann",
"Nico",
"Maximilian Krischan",
"Banana Joe",
"_ G3n",
"Donovan Jenkins",
"JBsuede",
"Hans Meier",
"Tú Nguyễn Lý Hoàng",
"shira1011",
"Michael Eid",
"beersandbacon",
"Maximilian Pyko",
"Invis",
"Justin Houston",
"Time Valentine",
"james",
"OrochiNights",
"Neko Desco",
"Bob barker",
"Ben D",
"G",
"Ronan Delevacq",
"karim ben brik",
"Vinarus",
"Michael Zhu",
"ACTUALLY_the_Real_Willem_Dafoe",
"gonzalo",
"Nemisu",
"Seraphy",
"Михал Михалыч",
"雨の心 落",
"Matt",
"AllTimeNoobie",
"Leslie Andrew Ridings",
"jumpd",
"John C",
"Rim",
"Dismem",
"Frogmilk",
"SPJ",
"Jairus Knudsen",
"Poophead27 Blyat",
"Xan Dionysus",
"Nathan lee",
"Mewtora",
"Lyle Liston",
"Middo",
"Forbidden Atelier",
"Bryan Rutkowski",
"Thomas Sankowski",
"Spire",
"DrB",
"AZ Party Oasis",
"Adictedtohumping",
"Towelie",
"Cyrus Fett",
"Ryan Smith",
"MR.Bear",
"matt",
"dsffsdfsdfsdfsdfsdf",
"somethingtosay8",
"Jean-françois SEMA",
"3zS4QNQ4",
"Terminuz",
"Kurt",
"max blo",
"Xenon Xue",
"JackJohnnyJim",
"Edward Ten Eyck",
"ivistorm",
"Ivan Imes",
"Faburizu",
"Jack Lawfield",
"jimyjomson",
"Borte",
"Chase Kwon",
"Ted Cart",
"Sage Himeros",
"Inyoshu",
"Goober719",
"Chad Barnes",
"Person Y",
"David Spearing",
"James Ming",
"vanditking",
"kripitonga",
"Rizzi",
"nimin",
"OMAR LUCIANO",
"Ken+Suzuki",
"hannibal",
"Jo+Example",
"BrentBertram",
"inusanorthcape",
"Tigon",
"eumelzocker",
"dxjaymz",
"L C",
"Dude"
"Dude",
"Somebody",
"CK"
],
"totalCount": 666
"totalCount": 773
}

View File

@@ -1,183 +0,0 @@
## Overview
The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com). With this extension, you can:
✅ Instantly see which models are already present in your local library
✅ Download new models with a single click
✅ Manage downloads efficiently with queue and parallel download support
✅ Keep your downloaded models automatically organized according to your custom settings
![Civitai Models page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-models-page.png)
**Update:** It now also supports browsing on [CivArchive](https://civarchive.com/) (formerly CivitaiArchive).
![CivArchive Models page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civarchive-models-page.png)
---
## Why Supporter Access?
LoRA Manager is built with love for the Stable Diffusion and ComfyUI communities. Your support makes it possible for me to keep improving and maintaining the tool full-time.
Supporter-exclusive features help ensure the long-term sustainability of LoRA Manager, allowing continuous updates, new features, and better performance for everyone.
Every contribution directly fuels development and keeps the core LoRA Manager free and open-source. In addition to monthly supporters, one-time donation supporters will also receive a license key, with the duration scaling according to the contribution amount. Thank you for helping keep this project alive and growing. ❤️
---
## Installation
### Supported Browsers & Installation Methods
| Browser | Installation Method |
|--------------------|-------------------------------------------------------------------------------------|
| **Google Chrome** | [Chrome Web Store link](https://chromewebstore.google.com/detail/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) |
| **Microsoft Edge** | Install via Chrome Web Store (compatible) |
| **Brave Browser** | Install via Chrome Web Store (compatible) |
| **Opera** | Install via Chrome Web Store (compatible) |
| **Firefox** | <div id="firefox-install" class="install-ok"><a href="https://github.com/willmiao/lm-civitai-extension-firefox/releases/latest/download/extension.xpi">📦 Install Firefox Extension (reviewed and verified by Mozilla)</a></div> |
For non-Chrome browsers (e.g., Microsoft Edge), you can typically install extensions from the Chrome Web Store by following these steps: open the extensions Chrome Web Store page, click 'Get extension', then click 'Allow' when prompted to enable installations from other stores, and finally click 'Add extension' to complete the installation.
---
## Privacy & Security
I understand concerns around browser extensions and privacy, and I want to be fully transparent about how the **LM Civitai Extension** works:
- **Reviewed and Verified**
This extension has been **manually reviewed and approved by the Chrome Web Store**. The Firefox version uses the **exact same code** (only the packaging format differs) and has passed **Mozillas Add-on review**.
- **Minimal Network Access**
The only external server this extension connects to is:
**`https://willmiao.shop`** — used solely for **license validation**.
It does **not collect, transmit, or store any personal or usage data**.
No browsing history, no user IDs, no analytics, no hidden trackers.
- **Local-Only Model Detection**
Model detection and LoRA Manager communication all happen **locally** within your browser, directly interacting with your local LoRA Manager backend.
I value your trust and are committed to keeping your local setup private and secure. If you have any questions, feel free to reach out!
---
## How to Use
After installing the extension, you'll automatically receive a **7-day trial** to explore all features.
When the extension is correctly installed and your license is valid:
- Open **Civitai**, and you'll see visual indicators added by the extension on model cards, showing:
- ✅ Models already present in your local library
- ⬇️ A download button for models not in your library
Clicking the download button adds the corresponding model version to the download queue, waiting to be downloaded. You can set up to **5 models to download simultaneously**.
### Visual Indicators Appear On:
- **Home Page** — Featured models
- **Models Page**
- **Creator Profiles** — If the creator has set their models to be visible
- **Recommended Resources** — On individual model pages
### Version Buttons on Model Pages
On a specific model page, visual indicators also appear on version buttons, showing which versions are already in your local library.
**Starting from v0.4.8**, model pages use a dedicated download button for better compatibility. When switching to a specific version by clicking a version button:
- The new **dedicated download button** directly triggers download via **LoRA Manager**
- The **original download button** remains unchanged for standard browser downloads
![Civitai Model Page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-model-page.png)
### Hide Models Already in Library (Beta)
**New in v0.4.8**: A new **Hide models already in library (Beta)** option makes it easier to focus on models you haven't added yet. It can be enabled from Settings, or toggled quickly using **Ctrl + Shift + H** (macOS: **Command + Shift + H**).
### Resources on Image Pages — now shows in-library indicators for image resources plus one-click recipe import
- **One-Click Import Civitai Image as Recipe** — Import any Civitai image as a recipe with a single click in the Resources Used panel.
- **Auto-Queue Missing Assets** — In Settings you can decide if LoRAs or checkpoints referenced by that image should automatically be added to your download queue.
- **More Accurate Metadata** — Importing directly from the page is faster than copying inside LM and keeps on-site tags and other metadata perfectly aligned.
![Civitai Image Page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-image-page.jpg)
[![alt](url)](https://github.com/user-attachments/assets/41fd4240-c949-4f83-bde7-8f3124c09494)
---
## Model Download Location & LoRA Manager Settings
To use the **one-click download function**, you must first set:
- Your **Default LoRAs Root**
- Your **Default Checkpoints Root**
These are set within LoRA Manager's settings.
When everything is configured, downloaded model files will be placed in:
`<Default_Models_Root>/<Base_Model_of_the_Model>/<First_Tag_of_the_Model>`
### Update: Default Path Customization (2025-07-21)
A new setting to customize the default download path has been added in the nightly version. You can now personalize where models are saved when downloading via the LM Civitai Extension.
![Default Path Customization](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/default-path-customization.png)
The previous YAML path mapping file will be deprecated—settings will now be unified in settings.json to simplify configuration.
---
## Backend Port Configuration
If your **ComfyUI** or **LoRA Manager** backend is running on a port **other than the default 8188**, you must configure the backend port in the extension's settings.
After correctly setting and saving the port, you'll see in the extension's header area:
- A **Healthy** status with the tooltip: `Connected to LoRA Manager on port xxxx`
---
## Advanced Usage
### Connecting to a Remote LoRA Manager
If your LoRA Manager is running on another computer, you can still connect from your browser using port forwarding.
> **Why can't you set a remote IP directly?**
>
> For privacy and security, the extension only requests access to `http://127.0.0.1/*`. Supporting remote IPs would require much broader permissions, which may be rejected by browser stores and could raise user concerns.
**Solution: Port Forwarding with `socat`**
On your browser computer, run:
`socat TCP-LISTEN:8188,bind=127.0.0.1,fork TCP:REMOTE.IP.ADDRESS.HERE:8188`
- Replace `REMOTE.IP.ADDRESS.HERE` with the IP of the machine running LoRA Manager.
- Adjust the port if needed.
This lets the extension connect to `127.0.0.1:8188` as usual, with traffic forwarded to your remote server.
_Thanks to user **Temikus** for sharing this solution!_
---
## Roadmap
The extension will evolve alongside **LoRA Manager** improvements. Planned features include:
- [x] Support for **additional model types** (e.g., embeddings)
- [x] One-click **Recipe Import**
- [x] Display of in-library status for all resources in the **Resources Used** section of the image page
- [x] One-click **Auto-organize Models**
- [x] **Hide models already in library (Beta)** - Focus on models you haven't added yet
**Stay tuned — and thank you for your support!**
---

View File

@@ -15,7 +15,8 @@
"settings": "Einstellungen",
"help": "Hilfe",
"add": "Hinzufügen",
"close": "Schließen"
"close": "Schließen",
"menu": "Menü"
},
"status": {
"loading": "Wird geladen...",
@@ -231,7 +232,10 @@
"license": "Lizenz",
"noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt",
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
"noTags": "Keine Tags",
"autoTags": "Auto-Tags",
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
"clearAll": "Alle Filter löschen",
"any": "Beliebig",
@@ -265,10 +269,10 @@
},
"downloadBackend": {
"label": "Download-Backend",
"help": "Wähle aus, wie Modelldateien heruntergeladen werden. Python verwendet den eingebauten Downloader. aria2 verwendet den experimentellen externen Downloader-Prozess.",
"help": "Wähle aus, wie Modelldateien heruntergeladen werden. Python verwendet den eingebauten Downloader. aria2 verwendet den empfohlenen externen Downloader-Prozess.",
"options": {
"python": "Python (integriert)",
"aria2": "aria2 (experimentell)"
"aria2": "aria2 (empfohlen)"
}
},
"aria2cPath": {
@@ -428,6 +432,8 @@
"hover": "Bei Hover anzeigen"
},
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
"showVersionOnCard": "Version auf Karte anzeigen",
"showVersionOnCardHelp": "Den Versionsnamen auf Modellkarten ein- oder ausblenden",
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
"modelCardFooterActionOptions": {
"exampleImages": "Beispielbilder öffnen",
@@ -573,7 +579,13 @@
},
"misc": {
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen"
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen",
"loraSyntaxFormat": "LoRA-Syntaxformat",
"loraSyntaxFormatHelp": "LoRA-Syntaxformat. Der vollständige Pfad enthält den Unterordnerpfad (<lora:style/anime/x:1.0>) für verlustfreie Modellauflösung. Legacy verwendet nur den Dateinamen (<lora:x:1.0>) — A1111-Konvention, kann bei doppelten Dateinamen in verschiedenen Ordnern zu Mehrdeutigkeiten führen.",
"loraSyntaxFormatOptions": {
"full": "Vollständiger Pfad (Unterordner/Name)",
"legacy": "Legacy A1111 (nur Name)"
}
},
"metadataArchive": {
"enableArchiveDb": "Metadaten-Archiv-Datenbank aktivieren",
@@ -637,8 +649,6 @@
},
"refresh": {
"title": "Modelliste aktualisieren",
"quick": "Änderungen synchronisieren",
"quickTooltip": "Nach neuen oder fehlenden Modelldateien suchen, damit die Liste aktuell bleibt.",
"full": "Cache neu aufbauen",
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
},
@@ -679,16 +689,29 @@
"setContentRating": "Inhaltsbewertung für alle festlegen",
"copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren",
"repairMetadata": "Metadaten der Auswahl reparieren",
"checkUpdates": "Auswahl auf Updates prüfen",
"moveAll": "Alle in Ordner verschieben",
"autoOrganize": "Automatisch organisieren",
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
"setFavorite": "Als Favorit setzen",
"setFavoriteCount": "Als Favorit setzen ({favorited}/{total})",
"unfavorite": "Aus Favoriten entfernen",
"deleteAll": "Ausgewählte löschen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
"downloadExamples": "Beispielbilder herunterladen",
"clear": "Auswahl löschen",
"skipMetadataRefreshCount": "Überspringen{count} Modelle",
"resumeMetadataRefreshCount": "Fortsetzen{count} Modelle",
"sendToWorkflow": "An Workflow senden",
"sections": {
"workflow": "Workflow",
"metadata": "Metadaten",
"attributes": "Attribute",
"organize": "Organisieren",
"download": "Download"
},
"autoOrganizeProgress": {
"initializing": "Automatische Organisation wird initialisiert...",
"starting": "Automatische Organisation für {type} wird gestartet...",
@@ -801,8 +824,6 @@
},
"refresh": {
"title": "Rezeptliste aktualisieren",
"quick": "Änderungen synchronisieren",
"quickTooltip": "Änderungen synchronisieren - schnelle Aktualisierung ohne Cache-Neubau",
"full": "Cache neu aufbauen",
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
},
@@ -1074,6 +1095,12 @@
"countMessage": "Modelle werden dauerhaft gelöscht.",
"action": "Alle löschen"
},
"bulkDeleteRecipes": {
"title": "Mehrere Rezepte löschen",
"message": "Sind Sie sicher, dass Sie alle ausgewählten Rezepte und ihre zugehörigen Dateien löschen möchten?",
"countMessage": "Rezepte werden dauerhaft gelöscht.",
"action": "Alle löschen"
},
"checkUpdates": {
"title": "Alle {typePlural} auf Updates prüfen?",
"message": "Damit werden alle {typePlural} in deiner Bibliothek auf Updates geprüft. Bei großen Sammlungen kann das etwas länger dauern.",
@@ -1154,6 +1181,7 @@
"editModelName": "Modellname bearbeiten",
"editFileName": "Dateiname bearbeiten",
"editBaseModel": "Basis-Modell bearbeiten",
"editVersionName": "Versionsname bearbeiten",
"viewOnCivitai": "Auf Civitai anzeigen",
"viewOnCivitaiText": "Auf Civitai anzeigen",
"viewCreatorProfile": "Ersteller-Profil anzeigen",
@@ -1290,12 +1318,15 @@
"earlyAccess": "Früher Zugriff",
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
"ignored": "Ignoriert",
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert"
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
"onSiteOnly": "Nur On-Site",
"onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar"
},
"actions": {
"download": "Herunterladen",
"downloadTooltip": "Diese Version herunterladen",
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
"delete": "Löschen",
"deleteTooltip": "Diese lokale Version löschen",
"ignore": "Ignorieren",
@@ -1663,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt",
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
},
@@ -1693,6 +1727,11 @@
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
"bulkFavoriteUpdating": "Füge {count} Modell(e) zu Favoriten hinzu...",
"bulkUnfavoriteUpdating": "Entferne {count} Modell(e) aus Favoriten...",
"bulkFavoritePartialAdded": "{success} Modell(e) zu Favoriten hinzugefügt, {failed} fehlgeschlagen",
"bulkFavoritePartialRemoved": "{success} Modell(e) aus Favoriten entfernt, {failed} fehlgeschlagen",
"bulkFavoriteFailed": "Fehler beim Aktualisieren des Favoritenstatus",
"bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",
@@ -1895,18 +1934,52 @@
"warning": "Handlungsbedarf",
"error": "Aktion erforderlich"
},
"issues": {
"civitai_api_key": {
"title": "Civitai API Key"
},
"cache_health": {
"title": "Model Cache Health"
},
"filename_conflicts": {
"title": "Duplicate Filename Conflicts"
},
"ui_version": {
"title": "UI Version"
}
},
"actions": {
"runAgain": "Erneut ausführen",
"exportBundle": "Paket exportieren"
"exportBundle": "Paket exportieren",
"open-settings": "Open Settings",
"open-settings-syntax-format": "Switch to Full Path Syntax",
"repair-cache": "Rebuild Cache",
"resolve-filename-conflicts": "Resolve Conflicts",
"reload-page": "Reload UI"
},
"labels": {
"conflicts": "Conflicts",
"version": "Version"
},
"toast": {
"loadFailed": "Diagnose konnte nicht geladen werden: {message}",
"repairSuccess": "Cache-Neuaufbau abgeschlossen.",
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
"exportSuccess": "Diagnosepaket exportiert.",
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}"
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}",
"conflictsResolved": "{count} Dateinamenskonflikt(e) gelöst.",
"conflictsResolveFailed": "Auflösung der Dateinamenskonflikte fehlgeschlagen: {message}"
}
},
"conflictConfirm": {
"title": "Dateinamenskonflikte auflösen",
"message": "Umbenennen durch Anhängen eines 4-stelligen Hashs an jeden doppelten Dateinamen.",
"note": "Dieser Vorgang benennt Dateien auf der Festplatte um. Modellreferenzen in vorhandenen Workflows müssen möglicherweise aktualisiert werden, wenn Sie das A1111-Syntaxformat verwenden.",
"detail": "Beispiel: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
"impact": "Benennt <strong>{count}</strong> Datei(en) in <strong>{groups}</strong> Duplikatgruppe(n) um",
"confirm": "Dateien umbenennen",
"cancel": "Abbrechen"
},
"banners": {
"versionMismatch": {
"title": "Anwendungs-Update erkannt",

View File

@@ -15,7 +15,8 @@
"settings": "Settings",
"help": "Help",
"add": "Add",
"close": "Close"
"close": "Close",
"menu": "Menu"
},
"status": {
"loading": "Loading...",
@@ -231,7 +232,10 @@
"license": "License",
"noCreditRequired": "No Credit Required",
"allowSellingGeneratedContent": "Allow Selling",
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
"noCreditRequiredTooltip": "Use the model without crediting the creator",
"noTags": "No tags",
"autoTags": "Auto Tags",
"noBaseModelMatches": "No base models match the current search.",
"clearAll": "Clear All Filters",
"any": "Any",
@@ -265,10 +269,10 @@
},
"downloadBackend": {
"label": "Download backend",
"help": "Choose how model files are downloaded. Python uses the built-in downloader. aria2 uses the experimental external downloader process.",
"help": "Choose how model files are downloaded. Python uses the built-in downloader. aria2 uses the recommended external downloader process.",
"options": {
"python": "Python (built-in)",
"aria2": "aria2 (experimental)"
"aria2": "aria2 (recommended)"
}
},
"aria2cPath": {
@@ -428,6 +432,8 @@
"hover": "Reveal on Hover"
},
"cardInfoDisplayHelp": "Choose when to display model information and action buttons",
"showVersionOnCard": "Show Version on Card",
"showVersionOnCardHelp": "Show or hide the version name on model cards",
"modelCardFooterAction": "Model Card Button Action",
"modelCardFooterActionOptions": {
"exampleImages": "Open Example Images",
@@ -573,7 +579,13 @@
},
"misc": {
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard"
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard",
"loraSyntaxFormat": "LoRA Syntax Format",
"loraSyntaxFormatHelp": "LoRA syntax format. Full includes subfolder path (<lora:style/anime/x:1.0>) for lossless model resolution. Legacy uses filename only (<lora:x:1.0>) — A1111 convention, may be ambiguous with duplicate filenames across folders.",
"loraSyntaxFormatOptions": {
"full": "Full path (subfolder/name)",
"legacy": "Legacy A1111 (name only)"
}
},
"metadataArchive": {
"enableArchiveDb": "Enable Metadata Archive Database",
@@ -637,8 +649,6 @@
},
"refresh": {
"title": "Refresh model list",
"quick": "Sync Changes",
"quickTooltip": "Scan for new or missing model files so the list stays current.",
"full": "Rebuild Cache",
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
},
@@ -679,16 +689,29 @@
"setContentRating": "Set Content Rating for Selected",
"copyAll": "Copy Selected Syntax",
"refreshAll": "Refresh Selected Metadata",
"repairMetadata": "Repair Metadata for Selected",
"checkUpdates": "Check Updates for Selected",
"moveAll": "Move Selected to Folder",
"autoOrganize": "Auto-Organize Selected",
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
"setFavorite": "Set as Favorite",
"setFavoriteCount": "Set as Favorite ({favorited}/{total})",
"unfavorite": "Remove from Favorites",
"deleteAll": "Delete Selected",
"downloadMissingLoras": "Download Missing LoRAs",
"downloadExamples": "Download Example Images",
"clear": "Clear Selection",
"skipMetadataRefreshCount": "Skip ({count} models)",
"resumeMetadataRefreshCount": "Resume ({count} models)",
"sendToWorkflow": "Send to Workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Metadata",
"attributes": "Attributes",
"organize": "Organize",
"download": "Download"
},
"autoOrganizeProgress": {
"initializing": "Initializing auto-organize...",
"starting": "Starting auto-organize for {type}...",
@@ -801,8 +824,6 @@
},
"refresh": {
"title": "Refresh recipe list",
"quick": "Sync Changes",
"quickTooltip": "Sync changes - quick refresh without rebuilding cache",
"full": "Rebuild Cache",
"fullTooltip": "Rebuild cache - full rescan of all recipe files"
},
@@ -1074,6 +1095,12 @@
"countMessage": "models will be permanently deleted.",
"action": "Delete All"
},
"bulkDeleteRecipes": {
"title": "Delete Multiple Recipes",
"message": "Are you sure you want to delete all selected recipes and their associated files?",
"countMessage": "recipes will be permanently deleted.",
"action": "Delete All"
},
"checkUpdates": {
"title": "Check updates for all {typePlural}?",
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",
@@ -1154,6 +1181,7 @@
"editModelName": "Edit model name",
"editFileName": "Edit file name",
"editBaseModel": "Edit base model",
"editVersionName": "Edit version name",
"viewOnCivitai": "View on Civitai",
"viewOnCivitaiText": "View on Civitai",
"viewCreatorProfile": "View Creator Profile",
@@ -1290,12 +1318,15 @@
"earlyAccess": "Early Access",
"earlyAccessTooltip": "This version currently requires Civitai early access",
"ignored": "Ignored",
"ignoredTooltip": "Update notifications are disabled for this version"
"ignoredTooltip": "Update notifications are disabled for this version",
"onSiteOnly": "On-Site Only",
"onSiteOnlyTooltip": "This version is only available for on-site generation on Civitai"
},
"actions": {
"download": "Download",
"downloadTooltip": "Download this version",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
"delete": "Delete",
"deleteTooltip": "Delete this local version",
"ignore": "Ignore",
@@ -1663,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No recipes selected",
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
"repairBulkFailed": "Failed to repair selected recipes: {message}",
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
},
@@ -1693,6 +1727,11 @@
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
"bulkContentRatingFailed": "Failed to update content rating for selected models",
"bulkFavoriteUpdating": "Adding {count} model(s) to favorites...",
"bulkUnfavoriteUpdating": "Removing {count} model(s) from favorites...",
"bulkFavoritePartialAdded": "Added {success} model(s) to favorites, {failed} failed",
"bulkFavoritePartialRemoved": "Removed {success} model(s) from favorites, {failed} failed",
"bulkFavoriteFailed": "Failed to update favorite status for selected models",
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
"bulkUpdatesNone": "No updates found for selected {type}(s)",
@@ -1895,18 +1934,52 @@
"warning": "Needs Attention",
"error": "Action Required"
},
"issues": {
"civitai_api_key": {
"title": "Civitai API Key"
},
"cache_health": {
"title": "Model Cache Health"
},
"filename_conflicts": {
"title": "Duplicate Filename Conflicts"
},
"ui_version": {
"title": "UI Version"
}
},
"actions": {
"runAgain": "Run Again",
"exportBundle": "Export Bundle"
"exportBundle": "Export Bundle",
"open-settings": "Open Settings",
"open-settings-syntax-format": "Switch to Full Path Syntax",
"repair-cache": "Rebuild Cache",
"resolve-filename-conflicts": "Resolve Conflicts",
"reload-page": "Reload UI"
},
"labels": {
"conflicts": "Conflicts",
"version": "Version"
},
"toast": {
"loadFailed": "Failed to load diagnostics: {message}",
"repairSuccess": "Cache rebuild completed.",
"repairFailed": "Cache rebuild failed: {message}",
"exportSuccess": "Diagnostics bundle exported.",
"exportFailed": "Failed to export diagnostics bundle: {message}"
"exportFailed": "Failed to export diagnostics bundle: {message}",
"conflictsResolved": "{count} filename conflict(s) resolved.",
"conflictsResolveFailed": "Failed to resolve filename conflicts: {message}"
}
},
"conflictConfirm": {
"title": "Resolve Filename Conflicts",
"message": "Renaming by appending a 4-character hash to each duplicate filename.",
"note": "This operation renames files on disk. Model references in existing workflows may need updating if you use the A1111 syntax format.",
"detail": "Example: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
"impact": "Will rename <strong>{count}</strong> file(s) across <strong>{groups}</strong> duplicate group(s).",
"confirm": "Rename Files",
"cancel": "Cancel"
},
"banners": {
"versionMismatch": {
"title": "Application Update Detected",
@@ -1936,4 +2009,4 @@
"retry": "Retry"
}
}
}
}

View File

@@ -15,7 +15,8 @@
"settings": "Configuración",
"help": "Ayuda",
"add": "Añadir",
"close": "Cerrar"
"close": "Cerrar",
"menu": "Menú"
},
"status": {
"loading": "Cargando...",
@@ -231,7 +232,10 @@
"license": "Licencia",
"noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida",
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
"noTags": "Sin etiquetas",
"autoTags": "Etiquetas automáticas",
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
"clearAll": "Limpiar todos los filtros",
"any": "Cualquiera",
@@ -265,10 +269,10 @@
},
"downloadBackend": {
"label": "Backend de descarga",
"help": "Elige cómo se descargan los archivos del modelo. Python usa el descargador integrado. aria2 usa el proceso externo experimental de descarga.",
"help": "Elige cómo se descargan los archivos del modelo. Python usa el descargador integrado. aria2 usa el proceso externo recomendado de descarga.",
"options": {
"python": "Python (integrado)",
"aria2": "aria2 (experimental)"
"aria2": "aria2 (recomendado)"
}
},
"aria2cPath": {
@@ -428,6 +432,8 @@
"hover": "Mostrar al pasar el ratón"
},
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
"showVersionOnCard": "Mostrar versión en la tarjeta",
"showVersionOnCardHelp": "Mostrar u ocultar el nombre de versión en las tarjetas de modelo",
"modelCardFooterAction": "Acción del botón de tarjeta de modelo",
"modelCardFooterActionOptions": {
"exampleImages": "Abrir imágenes de ejemplo",
@@ -573,7 +579,13 @@
},
"misc": {
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles"
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles",
"loraSyntaxFormat": "Formato de sintaxis LoRA",
"loraSyntaxFormatHelp": "Formato de sintaxis LoRA. El formato completo incluye la ruta de la subcarpeta (<lora:style/anime/x:1.0>) para una resolución de modelo sin pérdidas. El formato heredado usa solo el nombre del archivo (<lora:x:1.0>) — convención A1111, puede ser ambiguo con nombres de archivo duplicados entre carpetas.",
"loraSyntaxFormatOptions": {
"full": "Ruta completa (subcarpeta/nombre)",
"legacy": "A1111 heredado (solo nombre)"
}
},
"metadataArchive": {
"enableArchiveDb": "Habilitar base de datos de archivo de metadatos",
@@ -637,8 +649,6 @@
},
"refresh": {
"title": "Actualizar lista de modelos",
"quick": "Sincronizar cambios",
"quickTooltip": "Busca archivos de modelo nuevos o faltantes para mantener la lista al día.",
"full": "Reconstruir caché",
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
},
@@ -679,16 +689,29 @@
"setContentRating": "Establecer clasificación de contenido para todos",
"copyAll": "Copiar toda la sintaxis",
"refreshAll": "Actualizar todos los metadatos",
"repairMetadata": "Reparar metadatos de la selección",
"checkUpdates": "Comprobar actualizaciones para la selección",
"moveAll": "Mover todos a carpeta",
"autoOrganize": "Auto-organizar seleccionados",
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
"setFavorite": "Marcar como favorito",
"setFavoriteCount": "Marcar como favorito ({favorited}/{total})",
"unfavorite": "Quitar de favoritos",
"deleteAll": "Eliminar seleccionados",
"downloadMissingLoras": "Descargar LoRAs faltantes",
"downloadExamples": "Descargar imágenes de ejemplo",
"clear": "Limpiar selección",
"skipMetadataRefreshCount": "Omitir{count} modelos",
"resumeMetadataRefreshCount": "Reanudar{count} modelos",
"sendToWorkflow": "Enviar al workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Metadatos",
"attributes": "Atributos",
"organize": "Organizar",
"download": "Descargar"
},
"autoOrganizeProgress": {
"initializing": "Inicializando auto-organización...",
"starting": "Iniciando auto-organización para {type}...",
@@ -801,8 +824,6 @@
},
"refresh": {
"title": "Actualizar lista de recetas",
"quick": "Sincronizar cambios",
"quickTooltip": "Sincronizar cambios - actualización rápida sin reconstruir caché",
"full": "Reconstruir caché",
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
},
@@ -1074,6 +1095,12 @@
"countMessage": "modelos serán eliminados permanentemente.",
"action": "Eliminar todo"
},
"bulkDeleteRecipes": {
"title": "Eliminar múltiples recetas",
"message": "¿Estás seguro de que quieres eliminar todas las recetas seleccionadas y sus archivos asociados?",
"countMessage": "recetas serán eliminadas permanentemente.",
"action": "Eliminar todo"
},
"checkUpdates": {
"title": "¿Comprobar actualizaciones para todos los {typePlural}?",
"message": "Esto comprobará las actualizaciones de todos los {typePlural} de tu biblioteca. En colecciones grandes puede tardar un poco más.",
@@ -1154,6 +1181,7 @@
"editModelName": "Editar nombre del modelo",
"editFileName": "Editar nombre de archivo",
"editBaseModel": "Editar modelo base",
"editVersionName": "Editar nombre de versión",
"viewOnCivitai": "Ver en Civitai",
"viewOnCivitaiText": "Ver en Civitai",
"viewCreatorProfile": "Ver perfil del creador",
@@ -1290,12 +1318,15 @@
"earlyAccess": "Acceso temprano",
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
"ignored": "Ignorada",
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión"
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
"onSiteOnly": "Solo en Sitio",
"onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai"
},
"actions": {
"download": "Descargar",
"downloadTooltip": "Descargar esta versión",
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
"delete": "Eliminar",
"deleteTooltip": "Eliminar esta versión local",
"ignore": "Ignorar",
@@ -1663,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No se han seleccionado recetas",
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
},
@@ -1693,6 +1727,11 @@
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
"bulkFavoriteUpdating": "Añadiendo {count} modelo(s) a favoritos...",
"bulkUnfavoriteUpdating": "Eliminando {count} modelo(s) de favoritos...",
"bulkFavoritePartialAdded": "{success} modelo(s) añadido(s) a favoritos, {failed} fallido(s)",
"bulkFavoritePartialRemoved": "{success} modelo(s) eliminado(s) de favoritos, {failed} fallido(s)",
"bulkFavoriteFailed": "Error al actualizar el estado de favorito",
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",
@@ -1895,18 +1934,52 @@
"warning": "Requiere atención",
"error": "Se requiere acción"
},
"issues": {
"civitai_api_key": {
"title": "Civitai API Key"
},
"cache_health": {
"title": "Model Cache Health"
},
"filename_conflicts": {
"title": "Duplicate Filename Conflicts"
},
"ui_version": {
"title": "UI Version"
}
},
"actions": {
"runAgain": "Ejecutar de nuevo",
"exportBundle": "Exportar paquete"
"exportBundle": "Exportar paquete",
"open-settings": "Open Settings",
"open-settings-syntax-format": "Switch to Full Path Syntax",
"repair-cache": "Rebuild Cache",
"resolve-filename-conflicts": "Resolve Conflicts",
"reload-page": "Reload UI"
},
"labels": {
"conflicts": "Conflicts",
"version": "Version"
},
"toast": {
"loadFailed": "Error al cargar los diagnósticos: {message}",
"repairSuccess": "Reconstrucción de caché completada.",
"repairFailed": "Error al reconstruir la caché: {message}",
"exportSuccess": "Paquete de diagnósticos exportado.",
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}"
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}",
"conflictsResolved": "{count} conflicto(s) de nombre de archivo resuelto(s).",
"conflictsResolveFailed": "Error al resolver conflictos de nombre de archivo: {message}"
}
},
"conflictConfirm": {
"title": "Resolver conflictos de nombres de archivo",
"message": "Renombrar añadiendo un hash de 4 caracteres a cada nombre de archivo duplicado.",
"note": "Esta operación renombra archivos en el disco. Es posible que las referencias a modelos en flujos de trabajo existentes deban actualizarse si usas el formato de sintaxis A1111.",
"detail": "Ejemplo: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
"impact": "Renombrará <strong>{count}</strong> archivo(s) en <strong>{groups}</strong> grupo(s) de duplicados",
"confirm": "Renombrar archivos",
"cancel": "Cancelar"
},
"banners": {
"versionMismatch": {
"title": "Actualización de la aplicación detectada",

View File

@@ -15,7 +15,8 @@
"settings": "Paramètres",
"help": "Aide",
"add": "Ajouter",
"close": "Fermer"
"close": "Fermer",
"menu": "Menu"
},
"status": {
"loading": "Chargement...",
@@ -231,7 +232,10 @@
"license": "Licence",
"noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée",
"allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées",
"noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur",
"noTags": "Aucun tag",
"autoTags": "Auto-Tags",
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
"clearAll": "Effacer tous les filtres",
"any": "N'importe quel",
@@ -265,10 +269,10 @@
},
"downloadBackend": {
"label": "Moteur de téléchargement",
"help": "Choisissez comment les fichiers de modèles sont téléchargés. Python utilise le téléchargeur intégré. aria2 utilise le processus externe expérimental de téléchargement.",
"help": "Choisissez comment les fichiers de modèles sont téléchargés. Python utilise le téléchargeur intégré. aria2 utilise le processus externe recommandé de téléchargement.",
"options": {
"python": "Python (intégré)",
"aria2": "aria2 (expérimental)"
"aria2": "aria2 (recommandé)"
}
},
"aria2cPath": {
@@ -428,6 +432,8 @@
"hover": "Révéler au survol"
},
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
"showVersionOnCard": "Afficher la version sur la carte",
"showVersionOnCardHelp": "Afficher ou masquer le nom de version sur les cartes de modèle",
"modelCardFooterAction": "Action du bouton de carte de modèle",
"modelCardFooterActionOptions": {
"exampleImages": "Ouvrir les images d'exemple",
@@ -573,7 +579,13 @@
},
"misc": {
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers"
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers",
"loraSyntaxFormat": "Format de syntaxe LoRA",
"loraSyntaxFormatHelp": "Format de syntaxe LoRA. Le format complet inclut le chemin du sous-dossier (<lora:style/anime/x:1.0>) pour une résolution de modèle sans perte. Le format hérité utilise uniquement le nom du fichier (<lora:x:1.0>) — convention A1111, peut être ambiguë en cas de noms de fichiers en double dans différents dossiers.",
"loraSyntaxFormatOptions": {
"full": "Chemin complet (sous-dossier/nom)",
"legacy": "A1111 hérité (nom uniquement)"
}
},
"metadataArchive": {
"enableArchiveDb": "Activer la base de données d'archive des métadonnées",
@@ -637,8 +649,6 @@
},
"refresh": {
"title": "Actualiser la liste des modèles",
"quick": "Synchroniser les changements",
"quickTooltip": "Analyse les nouveaux fichiers de modèle ou les fichiers manquants pour garder la liste à jour.",
"full": "Reconstruire le cache",
"fullTooltip": "Recharge tous les détails des modèles depuis les fichiers metadata — à utiliser si la bibliothèque paraît obsolète ou après des modifications manuelles."
},
@@ -679,16 +689,29 @@
"setContentRating": "Définir la classification du contenu pour tous",
"copyAll": "Copier toute la syntaxe",
"refreshAll": "Actualiser toutes les métadonnées",
"repairMetadata": "Réparer les métadonnées de la sélection",
"checkUpdates": "Vérifier les mises à jour pour la sélection",
"moveAll": "Déplacer tout vers un dossier",
"autoOrganize": "Auto-organiser la sélection",
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
"setFavorite": "Définir comme favori",
"setFavoriteCount": "Définir comme favori ({favorited}/{total})",
"unfavorite": "Retirer des favoris",
"deleteAll": "Supprimer la sélection",
"downloadMissingLoras": "Télécharger les LoRAs manquants",
"downloadExamples": "Télécharger les images d'exemple",
"clear": "Effacer la sélection",
"skipMetadataRefreshCount": "Ignorer{count} modèles",
"resumeMetadataRefreshCount": "Reprendre{count} modèles",
"sendToWorkflow": "Envoyer au workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Métadonnées",
"attributes": "Attributs",
"organize": "Organiser",
"download": "Télécharger"
},
"autoOrganizeProgress": {
"initializing": "Initialisation de l'auto-organisation...",
"starting": "Démarrage de l'auto-organisation pour {type}...",
@@ -801,8 +824,6 @@
},
"refresh": {
"title": "Actualiser la liste des recipes",
"quick": "Synchroniser les changements",
"quickTooltip": "Synchroniser les changements - actualisation rapide sans reconstruire le cache",
"full": "Reconstruire le cache",
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
},
@@ -1074,6 +1095,12 @@
"countMessage": "modèles seront définitivement supprimés.",
"action": "Tout supprimer"
},
"bulkDeleteRecipes": {
"title": "Supprimer plusieurs recipes",
"message": "Êtes-vous sûr de vouloir supprimer toutes les recipes sélectionnées et leurs fichiers associés ?",
"countMessage": "recipes seront définitivement supprimées.",
"action": "Tout supprimer"
},
"checkUpdates": {
"title": "Vérifier les mises à jour pour tous les {typePlural} ?",
"message": "Cette action vérifie les mises à jour pour tous les {typePlural} de votre bibliothèque. Les grandes collections peuvent prendre un peu plus de temps.",
@@ -1154,6 +1181,7 @@
"editModelName": "Modifier le nom du modèle",
"editFileName": "Modifier le nom de fichier",
"editBaseModel": "Modifier le modèle de base",
"editVersionName": "Modifier le nom de la version",
"viewOnCivitai": "Voir sur Civitai",
"viewOnCivitaiText": "Voir sur Civitai",
"viewCreatorProfile": "Voir le profil du créateur",
@@ -1290,12 +1318,15 @@
"earlyAccess": "Accès anticipé",
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
"ignored": "Ignorée",
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version"
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
"onSiteOnly": "Uniquement sur Site",
"onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai"
},
"actions": {
"download": "Télécharger",
"downloadTooltip": "Télécharger cette version",
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
"delete": "Supprimer",
"deleteTooltip": "Supprimer cette version locale",
"ignore": "Ignorer",
@@ -1663,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Aucune recette sélectionnée",
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})",
"repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} recettes sélectionnées",
"repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}",
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
},
@@ -1693,6 +1727,11 @@
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
"bulkFavoriteUpdating": "Ajout de {count} modèle(s) aux favoris...",
"bulkUnfavoriteUpdating": "Suppression de {count} modèle(s) des favoris...",
"bulkFavoritePartialAdded": "{success} modèle(s) ajouté(s) aux favoris, {failed} échec(s)",
"bulkFavoritePartialRemoved": "{success} modèle(s) retiré(s) des favoris, {failed} échec(s)",
"bulkFavoriteFailed": "Échec de la mise à jour du statut de favori",
"bulkUpdatesChecking": "Vérification des mises à jour pour les {type} sélectionnés...",
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés",
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",
@@ -1895,18 +1934,52 @@
"warning": "Nécessite une attention",
"error": "Action requise"
},
"issues": {
"civitai_api_key": {
"title": "Civitai API Key"
},
"cache_health": {
"title": "Model Cache Health"
},
"filename_conflicts": {
"title": "Duplicate Filename Conflicts"
},
"ui_version": {
"title": "UI Version"
}
},
"actions": {
"runAgain": "Relancer",
"exportBundle": "Exporter le lot"
"exportBundle": "Exporter le lot",
"open-settings": "Open Settings",
"open-settings-syntax-format": "Switch to Full Path Syntax",
"repair-cache": "Rebuild Cache",
"resolve-filename-conflicts": "Resolve Conflicts",
"reload-page": "Reload UI"
},
"labels": {
"conflicts": "Conflicts",
"version": "Version"
},
"toast": {
"loadFailed": "Échec du chargement des diagnostics : {message}",
"repairSuccess": "Reconstruction du cache terminée.",
"repairFailed": "Échec de la reconstruction du cache : {message}",
"exportSuccess": "Lot de diagnostics exporté.",
"exportFailed": "Échec de l'export du lot de diagnostics : {message}"
"exportFailed": "Échec de l'export du lot de diagnostics : {message}",
"conflictsResolved": "{count} conflit(s) de nom de fichier résolu(s).",
"conflictsResolveFailed": "Échec de la résolution des conflits de nom de fichier : {message}"
}
},
"conflictConfirm": {
"title": "Résoudre les conflits de noms de fichiers",
"message": "Renommer en ajoutant un hachage de 4 caractères à chaque nom de fichier en double.",
"note": "Cette opération renomme les fichiers sur le disque. Les références de modèle dans les workflows existants peuvent nécessiter une mise à jour si vous utilisez le format de syntaxe A1111.",
"detail": "Exemple : <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
"impact": "Renommera <strong>{count}</strong> fichier(s) dans <strong>{groups}</strong> groupe(s) de doublons",
"confirm": "Renommer les fichiers",
"cancel": "Annuler"
},
"banners": {
"versionMismatch": {
"title": "Mise à jour de l'application détectée",

View File

@@ -15,7 +15,8 @@
"settings": "הגדרות",
"help": "עזרה",
"add": "הוספה",
"close": "סגור"
"close": "סגור",
"menu": "תפריט"
},
"status": {
"loading": "טוען...",
@@ -231,7 +232,10 @@
"license": "רישיון",
"noCreditRequired": "ללא קרדיט נדרש",
"allowSellingGeneratedContent": "אפשר מכירה",
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
"noTags": "ללא תגיות",
"autoTags": "תגיות אוטומטיות",
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
"clearAll": "נקה את כל המסננים",
"any": "כלשהו",
@@ -265,10 +269,10 @@
},
"downloadBackend": {
"label": "מנגנון הורדה",
"help": "בחר כיצד יורדים קבצי המודל. Python משתמש במוריד המובנה. aria2 משתמש בתהליך הורדה חיצוני ניסיוני.",
"help": "בחר כיצד יורדים קבצי המודל. Python משתמש במוריד המובנה. aria2 משתמש בתהליך הורדה חיצוני מומלץ.",
"options": {
"python": "Python (מובנה)",
"aria2": "aria2 (ניסיוני)"
"aria2": "aria2 (מומלץ)"
}
},
"aria2cPath": {
@@ -428,6 +432,8 @@
"hover": "חשוף בריחוף"
},
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
"showVersionOnCard": "הצג גרסה בכרטיס",
"showVersionOnCardHelp": "הצג או הסתר את שם הגרסה בכרטיסי המודל",
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
"modelCardFooterActionOptions": {
"exampleImages": "פתח תמונות דוגמה",
@@ -573,7 +579,13 @@
},
"misc": {
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח"
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח",
"loraSyntaxFormat": "פורמט תחביר LoRA",
"loraSyntaxFormatHelp": "פורמט תחביר LoRA. נתיב מלא כולל תת-תיקייה (<lora:style/anime/x:1.0>) לפתרון מודל ללא אובדן. גרסה ישנה משתמשת בשם קובץ בלבד (<lora:x:1.0>) — מוסכמת A1111, עלולה להיות לא חד משמעית עם שמות קבצים כפולים בתיקיות שונות.",
"loraSyntaxFormatOptions": {
"full": "נתיב מלא (תת-תיקייה/שם)",
"legacy": "A1111 ישן (שם בלבד)"
}
},
"metadataArchive": {
"enableArchiveDb": "הפעל מסד נתונים של ארכיון מטא-דאטה",
@@ -637,8 +649,6 @@
},
"refresh": {
"title": "רענן רשימת מודלים",
"quick": "סנכרון שינויים",
"quickTooltip": "סריקה לאיתור קבצי מודל חדשים או חסרים כדי לשמור את הרשימה מעודכנת.",
"full": "בניית מטמון מחדש",
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
},
@@ -679,16 +689,29 @@
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
"copyAll": "העתק את כל התחבירים",
"refreshAll": "רענן את כל המטא-דאטה",
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
"checkUpdates": "בדוק עדכונים לבחירה",
"moveAll": "העבר הכל לתיקייה",
"autoOrganize": "ארגן אוטומטית נבחרים",
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
"setFavorite": "הגדר כמועדף",
"setFavoriteCount": "הגדר כמועדף ({favorited}/{total})",
"unfavorite": "הסר ממועדפים",
"deleteAll": "מחק נבחרים",
"downloadMissingLoras": "הורדת LoRAs חסרים",
"downloadExamples": "הורד תמונות דוגמה",
"clear": "נקה בחירה",
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
"sendToWorkflow": "שלח ל-Workflow",
"sections": {
"workflow": "Workflow",
"metadata": "מטא-נתונים",
"attributes": "מאפיינים",
"organize": "ארגן",
"download": "הורדה"
},
"autoOrganizeProgress": {
"initializing": "מאתחל ארגון אוטומטי...",
"starting": "מתחיל ארגון אוטומטי עבור {type}...",
@@ -801,8 +824,6 @@
},
"refresh": {
"title": "רענן רשימת מתכונים",
"quick": "סנכרן שינויים",
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
"full": "בנה מטמון מחדש",
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
},
@@ -1074,6 +1095,12 @@
"countMessage": "מודלים יימחקו לצמיתות.",
"action": "מחק הכל"
},
"bulkDeleteRecipes": {
"title": "מחק מספר מתכונים",
"message": "האם אתה בטוח שברצונך למחוק את כל המתכונים שנבחרו ואת הקבצים הנלווים אליהם?",
"countMessage": "מתכונים יימחקו לצמיתות.",
"action": "מחק הכל"
},
"checkUpdates": {
"title": "לבדוק עדכונים לכל ה-{typePlural}?",
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
@@ -1154,6 +1181,7 @@
"editModelName": "ערוך שם מודל",
"editFileName": "ערוך שם קובץ",
"editBaseModel": "ערוך מודל בסיס",
"editVersionName": "ערוך שם גרסה",
"viewOnCivitai": "הצג ב-Civitai",
"viewOnCivitaiText": "הצג ב-Civitai",
"viewCreatorProfile": "הצג פרופיל יוצר",
@@ -1290,12 +1318,15 @@
"earlyAccess": "גישה מוקדמת",
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
"ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו"
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
"onSiteOnly": "רק באתר",
"onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai"
},
"actions": {
"download": "הורדה",
"downloadTooltip": "הורד את הגרסה הזו",
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
"delete": "מחיקה",
"deleteTooltip": "מחק את הגרסה המקומית הזו",
"ignore": "התעלם",
@@ -1663,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "לא נבחרו מתכונים",
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
},
@@ -1693,6 +1727,11 @@
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
"bulkFavoriteUpdating": "מוסיף {count} דגמים למועדפים...",
"bulkUnfavoriteUpdating": "מסיר {count} דגמים ממועדפים...",
"bulkFavoritePartialAdded": "{success} דגמים נוספו למועדפים, {failed} נכשלו",
"bulkFavoritePartialRemoved": "{success} דגמים הוסרו ממועדפים, {failed} נכשלו",
"bulkFavoriteFailed": "עדכון סטטוס מועדפים נכשל",
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",
@@ -1895,18 +1934,52 @@
"warning": "דורש תשומת לב",
"error": "נדרשת פעולה"
},
"issues": {
"civitai_api_key": {
"title": "Civitai API Key"
},
"cache_health": {
"title": "Model Cache Health"
},
"filename_conflicts": {
"title": "Duplicate Filename Conflicts"
},
"ui_version": {
"title": "UI Version"
}
},
"actions": {
"runAgain": "הפעל שוב",
"exportBundle": "ייצוא חבילה"
"exportBundle": "ייצוא חבילה",
"open-settings": "Open Settings",
"open-settings-syntax-format": "Switch to Full Path Syntax",
"repair-cache": "Rebuild Cache",
"resolve-filename-conflicts": "Resolve Conflicts",
"reload-page": "Reload UI"
},
"labels": {
"conflicts": "Conflicts",
"version": "Version"
},
"toast": {
"loadFailed": "טעינת האבחון נכשלה: {message}",
"repairSuccess": "בניית המטמון מחדש הושלמה.",
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
"exportSuccess": "חבילת האבחון יוצאה.",
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}"
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}",
"conflictsResolved": "נפתרו {count} התנגשויות בשמות קבצים.",
"conflictsResolveFailed": "פתרון התנגשויות שמות קבצים נכשל: {message}"
}
},
"conflictConfirm": {
"title": "פתור התנגשויות בשמות קבצים",
"message": "שינוי שם על ידי הוספת האש באורך 4 תווים לכל שם קובץ כפול.",
"note": "פעולה זו משנה שמות של קבצים בדיסק. ייתכן שיהיה צורך לעדכן הפניות למודלים בזרימות עבודה קיימות אם אתה משתמש בפורמט התחביר A1111.",
"detail": "דוגמה: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
"impact": "ישנה שם של <strong>{count}</strong> קבצים ב-<strong>{groups}</strong> קבוצות כפולות",
"confirm": "שנה שמות קבצים",
"cancel": "ביטול"
},
"banners": {
"versionMismatch": {
"title": "זוהה עדכון יישום",

View File

@@ -15,7 +15,8 @@
"settings": "設定",
"help": "ヘルプ",
"add": "追加",
"close": "閉じる"
"close": "閉じる",
"menu": "メニュー"
},
"status": {
"loading": "読み込み中...",
@@ -231,7 +232,10 @@
"license": "ライセンス",
"noCreditRequired": "クレジット不要",
"allowSellingGeneratedContent": "販売許可",
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
"noTags": "タグなし",
"autoTags": "自動タグ",
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
"clearAll": "すべてのフィルタをクリア",
"any": "いずれか",
@@ -265,10 +269,10 @@
},
"downloadBackend": {
"label": "ダウンロードバックエンド",
"help": "モデルファイルのダウンロード方法を選択します。Python は内蔵ダウンローダーを使用し、aria2 は実験的な外部ダウンローダープロセスを使用します。",
"help": "モデルファイルのダウンロード方法を選択します。Python は内蔵ダウンローダーを使用し、aria2 は推奨の外部ダウンローダープロセスを使用します。",
"options": {
"python": "Python内蔵",
"aria2": "aria2実験的"
"aria2": "aria2推奨"
}
},
"aria2cPath": {
@@ -428,6 +432,8 @@
"hover": "ホバー時に表示"
},
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
"showVersionOnCard": "カードにバージョンを表示",
"showVersionOnCardHelp": "モデルカード上のバージョン名の表示/非表示を切り替えます",
"modelCardFooterAction": "モデルカードボタンのアクション",
"modelCardFooterActionOptions": {
"exampleImages": "例画像を開く",
@@ -573,7 +579,13 @@
},
"misc": {
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます"
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます",
"loraSyntaxFormat": "LoRA構文形式",
"loraSyntaxFormatHelp": "LoRA構文形式。フルパスはサブフォルダパスを含み<lora:style/anime/x:1.0>)、モデルをロスレスで解決します。レガシーはファイル名のみ(<lora:x:1.0>)— A1111規約ですが、フォルダ間でファイル名が重複する場合に曖昧になる可能性があります。",
"loraSyntaxFormatOptions": {
"full": "フルパス(サブフォルダ/名前)",
"legacy": "レガシーA1111名前のみ"
}
},
"metadataArchive": {
"enableArchiveDb": "メタデータアーカイブデータベースを有効化",
@@ -637,8 +649,6 @@
},
"refresh": {
"title": "モデルリストを更新",
"quick": "変更を同期",
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
"full": "キャッシュを再構築",
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
},
@@ -679,16 +689,29 @@
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
"copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新",
"repairMetadata": "選択したレシピのメタデータを修復",
"checkUpdates": "選択項目の更新を確認",
"moveAll": "すべてをフォルダに移動",
"autoOrganize": "自動整理を実行",
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
"setFavorite": "お気に入りに設定",
"setFavoriteCount": "お気に入りに設定 ({favorited}/{total})",
"unfavorite": "お気に入りから削除",
"deleteAll": "選択したものを削除",
"downloadMissingLoras": "不足している LoRA をダウンロード",
"downloadExamples": "例画像をダウンロード",
"clear": "選択をクリア",
"skipMetadataRefreshCount": "スキップ({count}モデル)",
"resumeMetadataRefreshCount": "再開({count}モデル)",
"sendToWorkflow": "ワークフローに送信",
"sections": {
"workflow": "ワークフロー",
"metadata": "メタデータ",
"attributes": "属性",
"organize": "整理",
"download": "ダウンロード"
},
"autoOrganizeProgress": {
"initializing": "自動整理を初期化中...",
"starting": "{type}の自動整理を開始中...",
@@ -801,8 +824,6 @@
},
"refresh": {
"title": "レシピリストを更新",
"quick": "変更を同期",
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
"full": "キャッシュを再構築",
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
},
@@ -1074,6 +1095,12 @@
"countMessage": "モデルが完全に削除されます。",
"action": "すべて削除"
},
"bulkDeleteRecipes": {
"title": "複数のレシピを削除",
"message": "選択したすべてのレシピと関連ファイルを削除してもよろしいですか?",
"countMessage": "レシピが完全に削除されます。",
"action": "すべて削除"
},
"checkUpdates": {
"title": "すべての{type}の更新を確認しますか?",
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
@@ -1154,6 +1181,7 @@
"editModelName": "モデル名を編集",
"editFileName": "ファイル名を編集",
"editBaseModel": "ベースモデルを編集",
"editVersionName": "バージョン名を編集",
"viewOnCivitai": "Civitaiで表示",
"viewOnCivitaiText": "Civitaiで表示",
"viewCreatorProfile": "作成者プロフィールを表示",
@@ -1290,12 +1318,15 @@
"earlyAccess": "早期アクセス",
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
"ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です"
"ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ",
"onSiteOnlyTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません"
},
"actions": {
"download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
"delete": "削除",
"deleteTooltip": "このローカルバージョンを削除",
"ignore": "無視",
@@ -1663,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "レシピが選択されていません",
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
},
@@ -1693,6 +1727,11 @@
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
"bulkFavoriteUpdating": "{count} 個のモデルをお気に入りに追加中...",
"bulkUnfavoriteUpdating": "{count} 個のモデルをお気に入りから削除中...",
"bulkFavoritePartialAdded": "{success} 個のモデルをお気に入りに追加、{failed} 個失敗",
"bulkFavoritePartialRemoved": "{success} 個のモデルをお気に入りから削除、{failed} 個失敗",
"bulkFavoriteFailed": "お気に入り状態の更新に失敗しました",
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
@@ -1895,18 +1934,52 @@
"warning": "要注意",
"error": "対応が必要"
},
"issues": {
"civitai_api_key": {
"title": "Civitai API キー"
},
"cache_health": {
"title": "モデルキャッシュの健全性"
},
"filename_conflicts": {
"title": "ファイル名重複競合"
},
"ui_version": {
"title": "UI バージョン"
}
},
"actions": {
"runAgain": "再実行",
"exportBundle": "パッケージをエクスポート"
"exportBundle": "パッケージをエクスポート",
"open-settings": "設定を開く",
"open-settings-syntax-format": "フルパス構文に切り替え",
"repair-cache": "キャッシュを再構築",
"resolve-filename-conflicts": "競合を解決",
"reload-page": "UI をリロード"
},
"labels": {
"conflicts": "競合",
"version": "バージョン"
},
"toast": {
"loadFailed": "診断の読み込みに失敗しました: {message}",
"repairSuccess": "キャッシュの再構築が完了しました。",
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
"exportSuccess": "診断パッケージをエクスポートしました。",
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}"
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}",
"conflictsResolved": "{count} 件のファイル名競合が解決されました。",
"conflictsResolveFailed": "ファイル名競合の解決に失敗しました: {message}"
}
},
"conflictConfirm": {
"title": "ファイル名の競合を解決",
"message": "重複したファイル名に4文字のハッシュを追加してリネームします。",
"note": "この操作はディスク上のファイルをリネームします。A1111 構文形式を使用している場合、既存のワークフロー内のモデル参照を更新する必要があるかもしれません。",
"detail": "例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
"impact": "<strong>{groups}</strong> 組の重複にわたって <strong>{count}</strong> 個のファイルをリネームします",
"confirm": "ファイルをリネーム",
"cancel": "キャンセル"
},
"banners": {
"versionMismatch": {
"title": "アプリケーション更新が検出されました",

View File

@@ -15,7 +15,8 @@
"settings": "설정",
"help": "도움말",
"add": "추가",
"close": "닫기"
"close": "닫기",
"menu": "메뉴"
},
"status": {
"loading": "로딩 중...",
@@ -231,7 +232,10 @@
"license": "라이선스",
"noCreditRequired": "크레딧 표기 없음",
"allowSellingGeneratedContent": "판매 허용",
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
"noTags": "태그 없음",
"autoTags": "자동 태그",
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
"clearAll": "모든 필터 지우기",
"any": "아무",
@@ -265,10 +269,10 @@
},
"downloadBackend": {
"label": "다운로드 백엔드",
"help": "모델 파일을 다운로드하는 방식을 선택합니다. Python은 내장 다운로더를 사용하고, aria2는 실험적인 외부 다운로더 프로세스를 사용합니다.",
"help": "모델 파일을 다운로드하는 방식을 선택합니다. Python은 내장 다운로더를 사용하고, aria2는 권장되는 외부 다운로더 프로세스를 사용합니다.",
"options": {
"python": "Python(내장)",
"aria2": "aria2(실험적)"
"aria2": "aria2(권장)"
}
},
"aria2cPath": {
@@ -428,6 +432,8 @@
"hover": "호버 시 표시"
},
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
"showVersionOnCard": "카드에 버전 표시",
"showVersionOnCardHelp": "모델 카드에 버전 이름 표시 여부를 전환합니다",
"modelCardFooterAction": "모델 카드 버튼 동작",
"modelCardFooterActionOptions": {
"exampleImages": "예시 이미지 열기",
@@ -573,7 +579,13 @@
},
"misc": {
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다"
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다",
"loraSyntaxFormat": "LoRA 구문 형식",
"loraSyntaxFormatHelp": "LoRA 구문 형식. 전체 경로는 하위 폴더 경로(<lora:style/anime/x:1.0>)를 포함하여 손실 없는 모델 해상도를 제공합니다. 레거시는 파일 이름만(<lora:x:1.0>) 사용 — A1111 규칙이지만, 폴더 간 파일명 중복 시 모호할 수 있습니다.",
"loraSyntaxFormatOptions": {
"full": "전체 경로(하위 폴더/이름)",
"legacy": "레거시 A1111(이름만)"
}
},
"metadataArchive": {
"enableArchiveDb": "메타데이터 아카이브 데이터베이스 활성화",
@@ -637,8 +649,6 @@
},
"refresh": {
"title": "모델 목록 새로고침",
"quick": "변경 사항 동기화",
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
"full": "캐시 재구성",
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
},
@@ -679,16 +689,29 @@
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
"copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침",
"repairMetadata": "선택한 레시피 메타데이터 복구",
"checkUpdates": "선택 항목 업데이트 확인",
"moveAll": "모두 폴더로 이동",
"autoOrganize": "자동 정리 선택",
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
"setFavorite": "즐겨찾기로 설정",
"setFavoriteCount": "즐겨찾기로 설정 ({favorited}/{total})",
"unfavorite": "즐겨찾기 해제",
"deleteAll": "선택된 항목 삭제",
"downloadMissingLoras": "누락된 LoRA 다운로드",
"downloadExamples": "예시 이미지 다운로드",
"clear": "선택 지우기",
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
"resumeMetadataRefreshCount": "재개({count}개 모델)",
"sendToWorkflow": "워크플로우로 보내기",
"sections": {
"workflow": "워크플로우",
"metadata": "메타데이터",
"attributes": "속성",
"organize": "정리",
"download": "다운로드"
},
"autoOrganizeProgress": {
"initializing": "자동 정리 초기화 중...",
"starting": "{type}에 대한 자동 정리 시작...",
@@ -801,8 +824,6 @@
},
"refresh": {
"title": "레시피 목록 새로고침",
"quick": "변경 사항 동기화",
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
"full": "캐시 재구성",
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
},
@@ -1074,6 +1095,12 @@
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
"action": "모두 삭제"
},
"bulkDeleteRecipes": {
"title": "여러 레시피 삭제",
"message": "선택된 모든 레시피와 관련 파일을 삭제하시겠습니까?",
"countMessage": "개의 레시피가 영구적으로 삭제됩니다.",
"action": "모두 삭제"
},
"checkUpdates": {
"title": "{type} 전체 업데이트를 확인할까요?",
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
@@ -1154,6 +1181,7 @@
"editModelName": "모델명 편집",
"editFileName": "파일명 편집",
"editBaseModel": "베이스 모델 편집",
"editVersionName": "버전명 편집",
"viewOnCivitai": "Civitai에서 보기",
"viewOnCivitaiText": "Civitai에서 보기",
"viewCreatorProfile": "제작자 프로필 보기",
@@ -1290,12 +1318,15 @@
"earlyAccess": "얼리 액세스",
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
"ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다"
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
"onSiteOnly": "사이트 내 전용",
"onSiteOnlyTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
},
"actions": {
"download": "다운로드",
"downloadTooltip": "이 버전 다운로드",
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
"delete": "삭제",
"deleteTooltip": "이 로컬 버전 삭제",
"ignore": "무시",
@@ -1663,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "선택한 레시피가 없습니다",
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
},
@@ -1693,6 +1727,11 @@
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
"bulkFavoriteUpdating": "{count}개 모델을 즐겨찾기에 추가 중...",
"bulkUnfavoriteUpdating": "{count}개 모델을 즐겨찾기에서 제거 중...",
"bulkFavoritePartialAdded": "{success}개 모델을 즐겨찾기에 추가, {failed}개 실패",
"bulkFavoritePartialRemoved": "{success}개 모델을 즐겨찾기에서 제거, {failed}개 실패",
"bulkFavoriteFailed": "즐겨찾기 상태 업데이트 실패",
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
@@ -1895,18 +1934,52 @@
"warning": "주의 필요",
"error": "조치 필요"
},
"issues": {
"civitai_api_key": {
"title": "Civitai API 키"
},
"cache_health": {
"title": "모델 캐시 상태"
},
"filename_conflicts": {
"title": "파일명 중복 충돌"
},
"ui_version": {
"title": "UI 버전"
}
},
"actions": {
"runAgain": "다시 실행",
"exportBundle": "번들 내보내기"
"exportBundle": "번들 내보내기",
"open-settings": "설정 열기",
"open-settings-syntax-format": "전체 경로 구문으로 전환",
"repair-cache": "캐시 재구축",
"resolve-filename-conflicts": "충돌 해결",
"reload-page": "UI 새로고침"
},
"labels": {
"conflicts": "충돌",
"version": "버전"
},
"toast": {
"loadFailed": "진단 로드 실패: {message}",
"repairSuccess": "캐시 재구성이 완료되었습니다.",
"repairFailed": "캐시 재구성 실패: {message}",
"exportSuccess": "진단 번들이 내보내졌습니다.",
"exportFailed": "진단 번들 내보내기 실패: {message}"
"exportFailed": "진단 번들 내보내기 실패: {message}",
"conflictsResolved": "{count}개 파일명 충돌이 해결되었습니다.",
"conflictsResolveFailed": "파일명 충돌 해결 실패: {message}"
}
},
"conflictConfirm": {
"title": "파일명 충돌 해결",
"message": "중복 파일명에 4자리 해시를 추가하여 이름을 변경합니다.",
"note": "이 작업은 디스크에 있는 파일의 이름을 변경합니다. A1111 구문 형식을 사용하는 경우 기존 워크플로우의 모델 참조를 업데이트해야 할 수 있습니다.",
"detail": "예시: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
"impact": "<strong>{groups}</strong>개 중복 그룹에서 <strong>{count}</strong>개 파일 이름을 변경합니다",
"confirm": "파일 이름 변경",
"cancel": "취소"
},
"banners": {
"versionMismatch": {
"title": "애플리케이션 업데이트 감지",

View File

@@ -15,7 +15,8 @@
"settings": "Настройки",
"help": "Справка",
"add": "Добавить",
"close": "Закрыть"
"close": "Закрыть",
"menu": "Меню"
},
"status": {
"loading": "Загрузка...",
@@ -231,7 +232,10 @@
"license": "Лицензия",
"noCreditRequired": "Без указания авторства",
"allowSellingGeneratedContent": "Продажа разрешена",
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
"noCreditRequiredTooltip": "Использование модели без указания автора",
"noTags": "Без тегов",
"autoTags": "Авто-теги",
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
"clearAll": "Очистить все фильтры",
"any": "Любой",
@@ -265,10 +269,10 @@
},
"downloadBackend": {
"label": "Бэкенд загрузки",
"help": "Выберите способ загрузки файлов моделей. Python использует встроенный загрузчик. aria2 использует экспериментальный внешний процесс загрузки.",
"help": "Выберите способ загрузки файлов моделей. Python использует встроенный загрузчик. aria2 использует рекомендуемый внешний процесс загрузки.",
"options": {
"python": "Python (встроенный)",
"aria2": "aria2 (экспериментальный)"
"aria2": "aria2 (рекомендуемый)"
}
},
"aria2cPath": {
@@ -428,6 +432,8 @@
"hover": "Показать при наведении"
},
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
"showVersionOnCard": "Показывать версию на карточке",
"showVersionOnCardHelp": "Показать или скрыть название версии на карточках моделей",
"modelCardFooterAction": "Действие кнопки карточки модели",
"modelCardFooterActionOptions": {
"exampleImages": "Открыть примеры изображений",
@@ -573,7 +579,13 @@
},
"misc": {
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена"
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена",
"loraSyntaxFormat": "Формат синтаксиса LoRA",
"loraSyntaxFormatHelp": "Формат синтаксиса LoRA. Полный путь включает подпапку (<lora:style/anime/x:1.0>) для безпотерьного разрешения модели. Устаревший использует только имя файла (<lora:x:1.0>) — соглашение A1111, может быть неоднозначным при дублировании имён файлов в разных папках.",
"loraSyntaxFormatOptions": {
"full": "Полный путь (подпапка/имя)",
"legacy": "Устаревший A1111 (только имя)"
}
},
"metadataArchive": {
"enableArchiveDb": "Включить архив метаданных",
@@ -637,8 +649,6 @@
},
"refresh": {
"title": "Обновить список моделей",
"quick": "Синхронизировать изменения",
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
"full": "Перестроить кэш",
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
},
@@ -679,16 +689,29 @@
"setContentRating": "Установить рейтинг контента для всех",
"copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные",
"repairMetadata": "Восстановить метаданные для выбранных",
"checkUpdates": "Проверить обновления для выбранных",
"moveAll": "Переместить все в папку",
"autoOrganize": "Автоматически организовать выбранные",
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
"setFavorite": "Добавить в избранное",
"setFavoriteCount": "Добавить в избранное ({favorited}/{total})",
"unfavorite": "Удалить из избранного",
"deleteAll": "Удалить выбранные",
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
"downloadExamples": "Загрузить примеры изображений",
"clear": "Очистить выбор",
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
"sendToWorkflow": "Отправить в Workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Метаданные",
"attributes": "Атрибуты",
"organize": "Организовать",
"download": "Скачать"
},
"autoOrganizeProgress": {
"initializing": "Инициализация автоматической организации...",
"starting": "Запуск автоматической организации для {type}...",
@@ -801,8 +824,6 @@
},
"refresh": {
"title": "Обновить список рецептов",
"quick": "Синхронизировать изменения",
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
"full": "Перестроить кэш",
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
},
@@ -1074,6 +1095,12 @@
"countMessage": "моделей будут удалены навсегда.",
"action": "Удалить все"
},
"bulkDeleteRecipes": {
"title": "Удалить несколько рецептов",
"message": "Вы уверены, что хотите удалить все выбранные рецепты и связанные с ними файлы?",
"countMessage": "рецептов будут удалены навсегда.",
"action": "Удалить все"
},
"checkUpdates": {
"title": "Проверить обновления для всех {typePlural}?",
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
@@ -1154,6 +1181,7 @@
"editModelName": "Редактировать название модели",
"editFileName": "Редактировать имя файла",
"editBaseModel": "Редактировать базовую модель",
"editVersionName": "Редактировать название версии",
"viewOnCivitai": "Посмотреть на Civitai",
"viewOnCivitaiText": "Посмотреть на Civitai",
"viewCreatorProfile": "Посмотреть профиль создателя",
@@ -1290,12 +1318,15 @@
"earlyAccess": "Ранний доступ",
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
"ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены"
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
"onSiteOnly": "Только на Сайте",
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте Civitai"
},
"actions": {
"download": "Скачать",
"downloadTooltip": "Скачать эту версию",
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
"delete": "Удалить",
"deleteTooltip": "Удалить эту локальную версию",
"ignore": "Игнорировать",
@@ -1663,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Рецепты не выбраны",
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
},
@@ -1693,6 +1727,11 @@
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
"bulkFavoriteUpdating": "Добавление {count} моделей в избранное...",
"bulkUnfavoriteUpdating": "Удаление {count} моделей из избранного...",
"bulkFavoritePartialAdded": "{success} моделей добавлено в избранное, {failed} не удалось",
"bulkFavoritePartialRemoved": "{success} моделей удалено из избранного, {failed} не удалось",
"bulkFavoriteFailed": "Не удалось обновить статус избранного",
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",
@@ -1895,18 +1934,52 @@
"warning": "Требует внимания",
"error": "Требуется действие"
},
"issues": {
"civitai_api_key": {
"title": "Civitai API Key"
},
"cache_health": {
"title": "Model Cache Health"
},
"filename_conflicts": {
"title": "Duplicate Filename Conflicts"
},
"ui_version": {
"title": "UI Version"
}
},
"actions": {
"runAgain": "Запустить снова",
"exportBundle": "Экспортировать пакет"
"exportBundle": "Экспортировать пакет",
"open-settings": "Open Settings",
"open-settings-syntax-format": "Switch to Full Path Syntax",
"repair-cache": "Rebuild Cache",
"resolve-filename-conflicts": "Resolve Conflicts",
"reload-page": "Reload UI"
},
"labels": {
"conflicts": "Conflicts",
"version": "Version"
},
"toast": {
"loadFailed": "Не удалось загрузить диагностику: {message}",
"repairSuccess": "Перестройка кэша завершена.",
"repairFailed": "Не удалось перестроить кэш: {message}",
"exportSuccess": "Диагностический пакет экспортирован.",
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}"
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}",
"conflictsResolved": "Разрешено конфликтов имён файлов: {count}.",
"conflictsResolveFailed": "Не удалось разрешить конфликты имён файлов: {message}"
}
},
"conflictConfirm": {
"title": "Разрешить конфликты имён файлов",
"message": "Переименование с добавлением 4-символьного хеша к каждому дублирующемуся имени файла.",
"note": "Эта операция переименовывает файлы на диске. Если вы используете синтаксис A1111, ссылки на модели в существующих рабочих процессах могут потребовать обновления.",
"detail": "Пример: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
"impact": "Будет переименовано <strong>{count}</strong> файл(ов) в <strong>{groups}</strong> группе(ах) дубликатов",
"confirm": "Переименовать файлы",
"cancel": "Отмена"
},
"banners": {
"versionMismatch": {
"title": "Обнаружено обновление приложения",

View File

@@ -15,7 +15,8 @@
"settings": "设置",
"help": "帮助",
"add": "添加",
"close": "关闭"
"close": "关闭",
"menu": "菜单"
},
"status": {
"loading": "加载中...",
@@ -231,7 +232,10 @@
"license": "许可证",
"noCreditRequired": "无需署名",
"allowSellingGeneratedContent": "允许销售",
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
"noTags": "无标签",
"autoTags": "自动标签",
"noBaseModelMatches": "没有基础模型符合当前搜索。",
"clearAll": "清除所有筛选",
"any": "任一",
@@ -265,10 +269,10 @@
},
"downloadBackend": {
"label": "下载后端",
"help": "选择模型文件的下载方式。Python 使用内置下载器。aria2 使用实验性的外部下载进程。",
"help": "选择模型文件的下载方式。Python 使用内置下载器。aria2 使用推荐的外部下载进程。",
"options": {
"python": "Python内置",
"aria2": "aria2实验性"
"aria2": "aria2推荐"
}
},
"aria2cPath": {
@@ -428,6 +432,8 @@
"hover": "悬停时显示"
},
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
"showVersionOnCard": "在卡片上显示版本",
"showVersionOnCardHelp": "在模型卡片上显示或隐藏版本名称",
"modelCardFooterAction": "模型卡片按钮操作",
"modelCardFooterActionOptions": {
"exampleImages": "打开示例图片",
@@ -573,7 +579,13 @@
},
"misc": {
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词"
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词",
"loraSyntaxFormat": "LoRA 语法格式",
"loraSyntaxFormatHelp": "LoRA 语法格式。完整路径Full包含子文件夹路径 (<lora:style/anime/x:1.0>)解析精确无歧义。旧版Legacy仅使用文件名 (<lora:x:1.0>)——A1111 原始约定,同名文件跨文件夹时可能产生歧义。",
"loraSyntaxFormatOptions": {
"full": "完整路径(子文件夹/名称)",
"legacy": "旧版 A1111仅名称"
}
},
"metadataArchive": {
"enableArchiveDb": "启用元数据归档数据库",
@@ -637,8 +649,6 @@
},
"refresh": {
"title": "刷新模型列表",
"quick": "同步变更",
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
"full": "重建缓存",
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
},
@@ -679,16 +689,29 @@
"setContentRating": "为所选中设置内容评级",
"copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据",
"repairMetadata": "修复所选中元数据",
"checkUpdates": "检查所选更新",
"moveAll": "移动所选中到文件夹",
"autoOrganize": "自动整理所选模型",
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
"setFavorite": "设为收藏",
"setFavoriteCount": "设为收藏 ({favorited}/{total})",
"unfavorite": "取消收藏",
"deleteAll": "删除已选",
"downloadMissingLoras": "下载缺失的 LoRAs",
"downloadExamples": "下载示例图片",
"clear": "清除选择",
"skipMetadataRefreshCount": "跳过({count} 个模型)",
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
"sendToWorkflow": "发送到工作流",
"sections": {
"workflow": "工作流",
"metadata": "元数据",
"attributes": "属性",
"organize": "整理",
"download": "下载"
},
"autoOrganizeProgress": {
"initializing": "正在初始化自动整理...",
"starting": "正在为 {type} 启动自动整理...",
@@ -801,8 +824,6 @@
},
"refresh": {
"title": "刷新配方列表",
"quick": "同步变更",
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
"full": "重建缓存",
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
},
@@ -1074,6 +1095,12 @@
"countMessage": "模型将被永久删除。",
"action": "全部删除"
},
"bulkDeleteRecipes": {
"title": "删除多个配方",
"message": "你确定要删除所有选中的配方及其相关文件吗?",
"countMessage": "配方将被永久删除。",
"action": "全部删除"
},
"checkUpdates": {
"title": "检查所有 {type} 的更新?",
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
@@ -1154,6 +1181,7 @@
"editModelName": "编辑模型名称",
"editFileName": "编辑文件名",
"editBaseModel": "编辑基础模型",
"editVersionName": "编辑版本名称",
"viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看",
"viewCreatorProfile": "查看创作者主页",
@@ -1290,12 +1318,15 @@
"earlyAccess": "抢先体验",
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
"ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知"
"ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成",
"onSiteOnlyTooltip": "此版本仅在 Civitai 站内可用,无法下载"
},
"actions": {
"download": "下载",
"downloadTooltip": "下载此版本",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
"delete": "删除",
"deleteTooltip": "删除此本地版本",
"ignore": "忽略",
@@ -1663,6 +1694,9 @@
"batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方",
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
"repairBulkSkipped": "所选 {total} 个配方无需修复",
"repairBulkFailed": "修复所选配方失败:{message}",
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
},
@@ -1693,6 +1727,11 @@
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level}{failed} 个失败",
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
"bulkFavoriteUpdating": "正在将 {count} 个模型添加到收藏...",
"bulkUnfavoriteUpdating": "正在将 {count} 个模型从收藏移除...",
"bulkFavoritePartialAdded": "已将 {success} 个模型添加到收藏,{failed} 个失败",
"bulkFavoritePartialRemoved": "已将 {success} 个模型从收藏移除,{failed} 个失败",
"bulkFavoriteFailed": "更新收藏状态失败",
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
"bulkUpdatesNone": "所选 {type} 未发现更新",
@@ -1895,18 +1934,52 @@
"warning": "需要关注",
"error": "需要处理"
},
"issues": {
"civitai_api_key": {
"title": "Civitai API 密钥"
},
"cache_health": {
"title": "模型缓存健康状态"
},
"filename_conflicts": {
"title": "文件名重复冲突"
},
"ui_version": {
"title": "UI 版本"
}
},
"actions": {
"runAgain": "重新检查",
"exportBundle": "导出诊断包"
"exportBundle": "导出诊断包",
"open-settings": "打开设置",
"open-settings-syntax-format": "切换为完整路径语法",
"repair-cache": "重建缓存",
"resolve-filename-conflicts": "解决冲突",
"reload-page": "刷新 UI"
},
"labels": {
"conflicts": "冲突详情",
"version": "版本信息"
},
"toast": {
"loadFailed": "加载诊断结果失败:{message}",
"repairSuccess": "缓存重建完成。",
"repairFailed": "缓存重建失败:{message}",
"exportSuccess": "诊断包已导出。",
"exportFailed": "导出诊断包失败:{message}"
"exportFailed": "导出诊断包失败:{message}",
"conflictsResolved": "已解决 {count} 个文件名冲突。",
"conflictsResolveFailed": "解决文件名冲突失败:{message}"
}
},
"conflictConfirm": {
"title": "解决文件名冲突",
"message": "通过在每个重复文件名后附加 4 位哈希值来重命名文件。",
"note": "此操作会重命名磁盘上的文件。如果使用 A1111 语法格式,现有工作流中的模型引用可能需要更新。",
"detail": "示例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
"impact": "将重命名 <strong>{count}</strong> 个文件(共 <strong>{groups}</strong> 组重复)",
"confirm": "重命名文件",
"cancel": "取消"
},
"banners": {
"versionMismatch": {
"title": "检测到应用更新",

View File

@@ -15,7 +15,8 @@
"settings": "設定",
"help": "說明",
"add": "新增",
"close": "關閉"
"close": "關閉",
"menu": "選單"
},
"status": {
"loading": "載入中...",
@@ -231,7 +232,10 @@
"license": "授權",
"noCreditRequired": "無需署名",
"allowSellingGeneratedContent": "允許銷售",
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
"noTags": "無標籤",
"autoTags": "自動標籤",
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
"clearAll": "清除所有篩選",
"any": "任一",
@@ -265,10 +269,10 @@
},
"downloadBackend": {
"label": "下載後端",
"help": "選擇模型檔案的下載方式。Python 使用內建下載器。aria2 使用實驗性的外部下載程序。",
"help": "選擇模型檔案的下載方式。Python 使用內建下載器。aria2 使用推薦的外部下載程序。",
"options": {
"python": "Python內建",
"aria2": "aria2實驗性"
"aria2": "aria2推薦"
}
},
"aria2cPath": {
@@ -428,6 +432,8 @@
"hover": "滑鼠懸停顯示"
},
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
"showVersionOnCard": "在卡片上顯示版本",
"showVersionOnCardHelp": "在模型卡片上顯示或隱藏版本名稱",
"modelCardFooterAction": "模型卡片按鈕操作",
"modelCardFooterActionOptions": {
"exampleImages": "開啟範例圖片",
@@ -573,7 +579,13 @@
},
"misc": {
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞"
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞",
"loraSyntaxFormat": "LoRA 語法格式",
"loraSyntaxFormatHelp": "LoRA 語法格式。完整路徑Full包含子資料夾路徑 (<lora:style/anime/x:1.0>)解析精確無歧義。舊版Legacy僅使用檔名 (<lora:x:1.0>)——A1111 原始約定,同名檔案跨資料夾時可能產生歧義。",
"loraSyntaxFormatOptions": {
"full": "完整路徑(子資料夾/名稱)",
"legacy": "舊版 A1111僅名稱"
}
},
"metadataArchive": {
"enableArchiveDb": "啟用中繼資料封存資料庫",
@@ -637,8 +649,6 @@
},
"refresh": {
"title": "重新整理模型列表",
"quick": "同步變更",
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
"full": "重建快取",
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
},
@@ -679,16 +689,29 @@
"setContentRating": "為全部設定內容分級",
"copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata",
"repairMetadata": "修復所選中元數據",
"checkUpdates": "檢查所選更新",
"moveAll": "全部移動到資料夾",
"autoOrganize": "自動整理所選模型",
"skipMetadataRefresh": "跳過所選模型的元數據更新",
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
"setFavorite": "設為收藏",
"setFavoriteCount": "設為收藏 ({favorited}/{total})",
"unfavorite": "取消收藏",
"deleteAll": "刪除所選",
"downloadMissingLoras": "下載缺失的 LoRAs",
"downloadExamples": "下載範例圖片",
"clear": "清除選取",
"skipMetadataRefreshCount": "跳過({count} 個模型)",
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
"sendToWorkflow": "發送到工作流",
"sections": {
"workflow": "工作流",
"metadata": "元數據",
"attributes": "屬性",
"organize": "整理",
"download": "下載"
},
"autoOrganizeProgress": {
"initializing": "正在初始化自動整理...",
"starting": "正在開始自動整理 {type}...",
@@ -801,8 +824,6 @@
},
"refresh": {
"title": "重新整理配方列表",
"quick": "同步變更",
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
"full": "重建快取",
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
},
@@ -1074,6 +1095,12 @@
"countMessage": "模型將被永久刪除。",
"action": "全部刪除"
},
"bulkDeleteRecipes": {
"title": "刪除多個配方",
"message": "您確定要刪除所有選取的配方及其相關檔案嗎?",
"countMessage": "配方將被永久刪除。",
"action": "全部刪除"
},
"checkUpdates": {
"title": "要檢查所有 {type} 的更新嗎?",
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
@@ -1154,6 +1181,7 @@
"editModelName": "編輯模型名稱",
"editFileName": "編輯檔案名稱",
"editBaseModel": "編輯基礎模型",
"editVersionName": "編輯版本名稱",
"viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看",
"viewCreatorProfile": "查看創作者個人檔案",
@@ -1290,12 +1318,15 @@
"earlyAccess": "搶先體驗",
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
"ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知"
"ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成",
"onSiteOnlyTooltip": "此版本僅在 Civitai 站內可用,無法下載"
},
"actions": {
"download": "下載",
"downloadTooltip": "下載此版本",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
"delete": "刪除",
"deleteTooltip": "刪除此本地版本",
"ignore": "忽略",
@@ -1663,6 +1694,9 @@
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何食譜",
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
"repairBulkSkipped": "所選 {total} 個配方無需修復",
"repairBulkFailed": "修復所選配方失敗:{message}",
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
},
@@ -1693,6 +1727,11 @@
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level}{failed} 個失敗",
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
"bulkFavoriteUpdating": "正在將 {count} 個模型加入收藏...",
"bulkUnfavoriteUpdating": "正在將 {count} 個模型從收藏移除...",
"bulkFavoritePartialAdded": "已將 {success} 個模型加入收藏,{failed} 個失敗",
"bulkFavoritePartialRemoved": "已將 {success} 個模型從收藏移除,{failed} 個失敗",
"bulkFavoriteFailed": "更新收藏狀態失敗",
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
"bulkUpdatesNone": "所選 {type} 未找到更新",
@@ -1895,18 +1934,52 @@
"warning": "需要注意",
"error": "需要處理"
},
"issues": {
"civitai_api_key": {
"title": "Civitai API 金鑰"
},
"cache_health": {
"title": "模型快取健康狀態"
},
"filename_conflicts": {
"title": "檔案名稱重複衝突"
},
"ui_version": {
"title": "UI 版本"
}
},
"actions": {
"runAgain": "重新執行",
"exportBundle": "匯出套件"
"exportBundle": "匯出套件",
"open-settings": "開啟設定",
"open-settings-syntax-format": "切換為完整路徑語法",
"repair-cache": "重建快取",
"resolve-filename-conflicts": "解決衝突",
"reload-page": "重新載入 UI"
},
"labels": {
"conflicts": "衝突詳情",
"version": "版本"
},
"toast": {
"loadFailed": "載入診斷失敗:{message}",
"repairSuccess": "快取重建完成。",
"repairFailed": "快取重建失敗:{message}",
"exportSuccess": "診斷套件已匯出。",
"exportFailed": "匯出診斷套件失敗:{message}"
"exportFailed": "匯出診斷套件失敗:{message}",
"conflictsResolved": "已解決 {count} 個檔案名稱衝突。",
"conflictsResolveFailed": "解決檔案名稱衝突失敗:{message}"
}
},
"conflictConfirm": {
"title": "解決檔案名稱衝突",
"message": "通過在每個重複檔案名稱後附加 4 位元哈希值來重新命名檔案。",
"note": "此操作會重新命名磁碟上的檔案。如果使用 A1111 語法格式,現有工作流程中的模型參考可能需要更新。",
"detail": "示例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
"impact": "將重新命名 <strong>{count}</strong> 個檔案(共 <strong>{groups}</strong> 組重複)",
"confirm": "重新命名檔案",
"cancel": "取消"
},
"banners": {
"versionMismatch": {
"title": "偵測到應用程式更新",

View File

@@ -172,6 +172,12 @@ class Config:
self.extra_unet_roots: List[str] = []
self.extra_embeddings_roots: List[str] = []
self.recipes_path: str = ""
# Load extra folder paths from active library settings before symlink scan
# so both primary and extra paths are discovered in a single pass.
if not standalone_mode:
self._load_extra_paths_from_settings()
# Scan symbolic links during initialization
self._initialize_symlink_mappings()
@@ -179,6 +185,96 @@ class Config:
# Save the paths to settings.json when running in ComfyUI mode
self.save_folder_paths_to_settings()
def _load_extra_paths_from_settings(self) -> None:
"""Read extra folder paths from the active library and apply them.
Called during ``Config.__init__`` before the symlink scan so both primary and
extra paths are discovered in a single pass. Mirrors the extra-path
portion of ``_apply_library_paths`` without replacing the primary roots
that were already resolved from ComfyUI's ``folder_paths``.
"""
try:
from .services.settings_manager import get_settings_manager
settings_manager = get_settings_manager()
library_name = settings_manager.get_active_library_name()
libraries = settings_manager.get_libraries()
if not library_name or library_name not in libraries:
return
library_config = libraries[library_name]
if not isinstance(library_config, dict):
return
extra_folder_paths = library_config.get("extra_folder_paths")
if not isinstance(extra_folder_paths, dict):
return
extra_lora = extra_folder_paths.get("loras", []) or []
extra_checkpoint = extra_folder_paths.get("checkpoints", []) or []
extra_unet = extra_folder_paths.get("unet", []) or []
extra_embedding = extra_folder_paths.get("embeddings", []) or []
if not any([extra_lora, extra_checkpoint, extra_unet, extra_embedding]):
return
filtered_extra_lora = self._filter_overlapping_extra_lora_paths(
self.loras_roots, extra_lora
)
self.extra_loras_roots = self._prepare_lora_paths(filtered_extra_lora)
(
_,
self.extra_checkpoints_roots,
self.extra_unet_roots,
) = self._prepare_checkpoint_paths(extra_checkpoint, extra_unet)
self.extra_embeddings_roots = self._prepare_embedding_paths(
extra_embedding
)
recipes_path = library_config.get("recipes_path", "")
if isinstance(recipes_path, str) and recipes_path:
self.recipes_path = recipes_path
if self.extra_loras_roots:
logger.info(
"Found extra LoRA roots:"
+ "\n - "
+ "\n - ".join(self.extra_loras_roots)
)
if self.extra_checkpoints_roots:
logger.info(
"Found extra checkpoint roots:"
+ "\n - "
+ "\n - ".join(self.extra_checkpoints_roots)
)
if self.extra_unet_roots:
logger.info(
"Found extra diffusion model roots:"
+ "\n - "
+ "\n - ".join(self.extra_unet_roots)
)
if self.extra_embeddings_roots:
logger.info(
"Found extra embedding roots:"
+ "\n - "
+ "\n - ".join(self.extra_embeddings_roots)
)
logger.info(
"Applied library settings for '%s' with extra paths: loras=%s, "
"checkpoints=%s, embeddings=%s",
library_name,
extra_lora,
extra_checkpoint,
extra_embedding,
)
except Exception as exc:
logger.debug(
"Could not load extra paths from library settings: %s", exc
)
def save_folder_paths_to_settings(self):
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
try:

View File

@@ -184,39 +184,6 @@ class LoraManager:
async def _initialize_services(cls):
"""Initialize all services using the ServiceRegistry"""
try:
# Apply library settings to load extra folder paths before scanning
# Only apply if extra paths haven't been loaded yet (preserves test mocks)
try:
from .services.settings_manager import get_settings_manager
settings_manager = get_settings_manager()
library_name = settings_manager.get_active_library_name()
libraries = settings_manager.get_libraries()
if library_name and library_name in libraries:
library_config = libraries[library_name]
# Only apply settings if extra paths are not already configured
# This preserves values set by tests via monkeypatch
extra_paths = library_config.get("extra_folder_paths", {})
has_extra_paths = (
config.extra_loras_roots
or config.extra_checkpoints_roots
or config.extra_unet_roots
or config.extra_embeddings_roots
)
if not has_extra_paths and any(extra_paths.values()):
config.apply_library_settings(library_config)
logger.info(
"Applied library settings for '%s' with extra paths: loras=%s, checkpoints=%s, embeddings=%s",
library_name,
extra_paths.get("loras", []),
extra_paths.get("checkpoints", []),
extra_paths.get("embeddings", []),
)
except Exception as exc:
logger.warning(
"Failed to apply library settings during initialization: %s", exc
)
# Initialize CivitaiClient first to ensure it's ready for other services
await ServiceRegistry.get_civitai_client()

View File

@@ -560,8 +560,14 @@ class MetadataProcessor:
params["loras"] = " ".join(lora_parts)
# Set default clip_skip value
params["clip_skip"] = "1" # Common default
# Extract clip_skip from any SAMPLING node that provides it
for sampler_info in metadata.get(SAMPLING, {}).values():
clip_skip = sampler_info.get("parameters", {}).get("clip_skip")
if clip_skip is not None:
params["clip_skip"] = clip_skip
break
if params["clip_skip"] is None:
params["clip_skip"] = "1"
return params

View File

@@ -144,6 +144,118 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
class EasyComfyLoaderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
if "ckpt_name" in inputs:
_store_checkpoint_metadata(metadata, node_id, inputs["ckpt_name"])
# Only extract from optional_lora_stack — skip the single lora_name to
# avoid double-counting LoRAs that come through the LORA_STACK path.
active_loras = []
optional_lora_stack = inputs.get("optional_lora_stack")
if optional_lora_stack is not None and isinstance(optional_lora_stack, (list, tuple)):
for item in optional_lora_stack:
if isinstance(item, (list, tuple)) and len(item) >= 2:
lora_path = item[0]
model_strength = item[1]
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
active_loras.append({
"name": lora_name,
"strength": model_strength
})
if active_loras:
metadata[LORAS][node_id] = {
"lora_list": active_loras,
"node_id": node_id
}
positive_text = inputs.get("positive", "")
negative_text = inputs.get("negative", "")
if positive_text or negative_text:
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
metadata[PROMPTS][node_id]["positive_text"] = positive_text
metadata[PROMPTS][node_id]["negative_text"] = negative_text
if "clip_skip" in inputs:
clip_skip = inputs["clip_skip"]
if node_id not in metadata[SAMPLING]:
metadata[SAMPLING][node_id] = {"parameters": {}, "node_id": node_id}
metadata[SAMPLING][node_id]["parameters"]["clip_skip"] = clip_skip
width = inputs.get("empty_latent_width")
height = inputs.get("empty_latent_height")
if width is not None and height is not None:
if SIZE not in metadata:
metadata[SIZE] = {}
metadata[SIZE][node_id] = {
"width": int(width),
"height": int(height),
"node_id": node_id
}
@staticmethod
def update(node_id, outputs, metadata):
# outputs: [(pipe_dict, model, vae), ...]
if not outputs or not isinstance(outputs, list) or len(outputs) == 0:
return
first_output = outputs[0]
if not isinstance(first_output, tuple) or len(first_output) < 1:
return
pipe = first_output[0]
if not isinstance(pipe, dict):
return
positive_conditioning = pipe.get("positive")
negative_conditioning = pipe.get("negative")
if positive_conditioning is not None or negative_conditioning is not None:
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
if positive_conditioning is not None:
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
if negative_conditioning is not None:
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
class EasyPreSamplingExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
sampling_params = {}
for key in ("steps", "cfg", "sampler_name", "scheduler", "denoise", "seed"):
if key in inputs:
sampling_params[key] = inputs[key]
metadata[SAMPLING][node_id] = {
"parameters": sampling_params,
"node_id": node_id,
IS_SAMPLER: True
}
class EasySeedExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs or "seed" not in inputs:
return
metadata[SAMPLING][node_id] = {
"parameters": {"seed": inputs["seed"]},
"node_id": node_id,
IS_SAMPLER: False
}
class CLIPTextEncodeExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
@@ -1013,9 +1125,12 @@ NODE_EXTRACTORS = {
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
# ComfyUI-Easy-Use pre-sampling / seed
"samplerSettings": EasyPreSamplingExtractor, # easy preSampling
"easySeed": EasySeedExtractor, # easy seed
# Loaders
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
"comfyLoader": EasyComfyLoaderExtractor, # ComfyUI-Easy-Use easy comfyLoader
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku

View File

@@ -9,6 +9,7 @@ from ..utils.utils import get_lora_info_absolute
from .utils import (
FlexibleOptionalInputType,
any_type,
apply_lora_syntax_format,
detect_nunchaku_model_kind,
extract_lora_name,
get_loras_list,
@@ -52,7 +53,7 @@ def _collect_widget_entries(kwargs):
for lora in get_loras_list(kwargs):
if not lora.get("active", False):
continue
lora_name = lora["name"]
lora_name = apply_lora_syntax_format(lora["name"])
model_strength = float(lora["strength"])
clip_strength = float(lora.get("clipStrength", model_strength))
lora_path, trigger_words = get_lora_info_absolute(lora_name)

View File

@@ -1,6 +1,6 @@
import os
from ..utils.utils import get_lora_info
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list
from .utils import FlexibleOptionalInputType, any_type, apply_lora_syntax_format, extract_lora_name, get_loras_list
import logging
@@ -48,7 +48,7 @@ class LoraStackerLM:
if not lora.get('active', False):
continue
lora_name = lora['name']
lora_name = apply_lora_syntax_format(lora['name'])
model_strength = float(lora['strength'])
# Get clip strength - use model strength as default if not specified
clip_strength = float(lora.get('clipStrength', model_strength))

View File

@@ -44,11 +44,29 @@ import folder_paths # type: ignore
logger = logging.getLogger(__name__)
def get_lora_syntax_format():
try:
from ..services.settings_manager import get_settings_manager
return get_settings_manager().get("lora_syntax_format", "legacy")
except Exception:
return "legacy"
def apply_lora_syntax_format(name):
fmt = get_lora_syntax_format()
if fmt == "legacy":
return name.replace("\\", "/").rstrip("/").split("/")[-1]
return name
def extract_lora_name(lora_path):
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
# Get the basename without extension
basename = os.path.basename(lora_path)
return os.path.splitext(basename)[0]
normalized = lora_path.replace("\\", "/")
basename = os.path.basename(normalized)
name_no_ext = os.path.splitext(basename)[0]
dirname = os.path.dirname(normalized)
if dirname and dirname not in (".", "/") and not normalized.startswith("/"):
return apply_lora_syntax_format(f"{dirname}/{name_no_ext}")
return apply_lora_syntax_format(name_no_ext)
def get_loras_list(kwargs):

View File

@@ -1,10 +1,22 @@
import folder_paths # type: ignore
from ..utils.utils import get_lora_info
import os
from ..utils.utils import get_lora_info_absolute
from ..config import config
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
import logging
logger = logging.getLogger(__name__)
def _relpath_within_loras(abs_path):
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
for root in all_roots:
try:
return os.path.relpath(abs_path, root)
except ValueError:
continue
return os.path.basename(abs_path)
class WanVideoLoraSelectLM:
NAME = "WanVideo Lora Select (LoraManager)"
CATEGORY = "Lora Manager/stackers"
@@ -56,13 +68,13 @@ class WanVideoLoraSelectLM:
clip_strength = float(lora.get('clipStrength', model_strength))
# Get lora path and trigger words
lora_path, trigger_words = get_lora_info(lora_name)
lora_path, trigger_words = get_lora_info_absolute(lora_name)
# Create lora item for WanVideo format
lora_item = {
"path": folder_paths.get_full_path("loras", lora_path),
"path": lora_path,
"strength": model_strength,
"name": lora_path.split(".")[0],
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
"blocks": selected_blocks,
"layer_filter": layer_filter,
"low_mem_load": low_mem_load,

View File

@@ -1,11 +1,23 @@
import folder_paths # type: ignore
from ..utils.utils import get_lora_info
import os
from ..utils.utils import get_lora_info_absolute
from ..config import config
from .utils import any_type
import logging
# 初始化日志记录器
logger = logging.getLogger(__name__)
def _relpath_within_loras(abs_path):
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
for root in all_roots:
try:
return os.path.relpath(abs_path, root)
except ValueError:
continue
return os.path.basename(abs_path)
# 定义新节点的类
class WanVideoLoraTextSelectLM:
# 节点在UI中显示的名称
@@ -87,12 +99,12 @@ class WanVideoLoraTextSelectLM:
else:
continue
lora_path, trigger_words = get_lora_info(lora_name_raw)
lora_path, trigger_words = get_lora_info_absolute(lora_name_raw)
lora_item = {
"path": folder_paths.get_full_path("loras", lora_path),
"path": lora_path,
"strength": model_strength,
"name": lora_path.split(".")[0],
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
"blocks": selected_blocks,
"layer_filter": layer_filter,
"low_mem_load": low_mem_load,

View File

@@ -7,7 +7,7 @@ import re
from typing import Dict, List, Any, Optional, Tuple
from abc import ABC, abstractmethod
from ..config import config
from ..utils.constants import VALID_LORA_TYPES
from ..utils.constants import VALID_LORA_TYPES, VALID_CHECKPOINT_SUB_TYPES
from ..utils.civitai_utils import rewrite_preview_url
logger = logging.getLogger(__name__)
@@ -173,6 +173,20 @@ class RecipeMetadataParser(ABC):
checkpoint['isDeleted'] = True
return checkpoint
# Validate that the model type is actually a checkpoint.
# Unlike populate_lora_from_civitai which has this check,
# this function was missing type validation — allowing LoRA
# version data to be saved as the recipe's checkpoint when the
# wrong version ID was passed downstream (fixed in v2.7+).
model_type = civitai_data.get('model', {}).get('type', '').lower()
if model_type not in VALID_CHECKPOINT_SUB_TYPES:
logger.warning(
f"Cannot populate checkpoint: model version {civitai_data.get('id')} "
f"has type '{model_type}', expected one of {VALID_CHECKPOINT_SUB_TYPES}. "
f"Skipping checkpoint enrichment."
)
return checkpoint
if 'model' in civitai_data and 'name' in civitai_data['model']:
checkpoint['name'] = civitai_data['model']['name']

View File

@@ -16,55 +16,65 @@ class RecipeEnricher:
async def enrich_recipe(
recipe: Dict[str, Any],
civitai_client: Any,
request_params: Optional[Dict[str, Any]] = None
request_params: Optional[Dict[str, Any]] = None,
prefetched_civitai_meta_raw: Optional[Dict[str, Any]] = None,
prefetched_model_version_id: Optional[int] = None,
) -> bool:
"""
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
Args:
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
civitai_client: Authenticated Civitai client instance.
request_params: (Optional) Parameters from a user request (e.g. import).
prefetched_civitai_meta_raw: (Optional) Pre-fetched raw meta from Civitai
get_image_info, avoiding a duplicate API call.
prefetched_model_version_id: (Optional) Pre-fetched model version ID.
Returns:
bool: True if the recipe was modified, False otherwise.
"""
updated = False
gen_params = recipe.get("gen_params", {})
# 1. Fetch Civitai Info if available
# 1. Obtain Civitai metadata
civitai_meta = None
model_version_id = None
source_url = recipe.get("source_url") or recipe.get("source_path", "")
# Check if it's a Civitai image URL
image_id = extract_civitai_image_id(str(source_url))
if image_id:
try:
image_info = await civitai_client.get_image_info(
image_id, source_url=str(source_url)
)
if image_info:
# Handle nested meta often found in Civitai API responses
raw_meta = image_info.get("meta")
if isinstance(raw_meta, dict):
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
civitai_meta = raw_meta["meta"]
else:
civitai_meta = raw_meta
model_version_id = image_info.get("modelVersionId")
# If not at top level, check resources in meta
if not model_version_id and civitai_meta:
resources = civitai_meta.get("civitaiResources", [])
for res in resources:
if res.get("type") == "checkpoint":
model_version_id = res.get("modelVersionId")
break
except Exception as e:
logger.warning(f"Failed to fetch Civitai image info: {e}")
model_version_id = prefetched_model_version_id
source_path = recipe.get("source_path", "")
if prefetched_civitai_meta_raw is not None:
raw_meta = prefetched_civitai_meta_raw
if isinstance(raw_meta, dict):
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
civitai_meta = raw_meta["meta"]
else:
civitai_meta = raw_meta
else:
image_id = extract_civitai_image_id(str(source_path))
if image_id:
try:
image_info = await civitai_client.get_image_info(
image_id, source_url=str(source_path)
)
if image_info:
raw_meta = image_info.get("meta")
if isinstance(raw_meta, dict):
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
civitai_meta = raw_meta["meta"]
else:
civitai_meta = raw_meta
model_version_id = image_info.get("modelVersionId")
except Exception as e:
logger.warning(f"Failed to fetch Civitai image info: {e}")
if not model_version_id and civitai_meta:
resources = civitai_meta.get("civitaiResources", [])
for res in resources:
if res.get("type") == "checkpoint":
model_version_id = res.get("modelVersionId")
break
# 2. Merge Parameters
# Priority: request_params > civitai_meta > embedded (existing gen_params)

View File

@@ -185,8 +185,67 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Process standard resources array
if "resources" in metadata and isinstance(metadata["resources"], list):
for resource in metadata["resources"]:
resource_type = resource.get("type", "lora")
# Track resources with type "model" — these are checkpoint models.
# The resources array is the most reliable source for checkpoint
# identification because it has an explicit type field and hash,
# unlike modelVersionIds which is a flat list with no type info.
if resource_type == "model":
checkpoint_entry = {
"id": 0,
"modelId": 0,
"name": resource.get("name", "Unknown Model"),
"version": "",
"type": resource.get("type", "model"),
"existsLocally": False,
"localPath": None,
"file_name": resource.get("name", ""),
"hash": resource.get("hash", "") or "",
"thumbnailUrl": "/loras_static/images/no-preview.png",
"baseModel": "",
"size": 0,
"downloadUrl": "",
"isDeleted": False,
}
# Try to look up base model from the checkpoint hash
if checkpoint_entry["hash"] and metadata_provider:
try:
civitai_info = (
await metadata_provider.get_model_by_hash(
checkpoint_entry["hash"]
)
)
civitai_data, error_msg = (
(civitai_info, None)
if not isinstance(civitai_info, tuple)
else civitai_info
)
if civitai_data and error_msg != "Model not found":
if 'model' in civitai_data and 'name' in civitai_data['model']:
checkpoint_entry['name'] = civitai_data['model']['name']
checkpoint_entry['id'] = civitai_data.get('id', 0)
checkpoint_entry['modelId'] = civitai_data.get('modelId', 0)
if 'name' in civitai_data:
checkpoint_entry['version'] = civitai_data['name']
base_model = civitai_data.get('baseModel', '')
if base_model:
checkpoint_entry['baseModel'] = base_model
if not result['base_model']:
result['base_model'] = base_model
except Exception as e:
logger.error(
f"Error fetching checkpoint info for hash "
f"{checkpoint_entry['hash']}: {e}"
)
if result["model"] is None:
result["model"] = checkpoint_entry
continue
# Modified to process resources without a type field as potential LoRAs
if resource.get("type", "lora") == "lora":
if resource_type == "lora":
lora_hash = resource.get("hash", "")
# Try to get hash from the hashes field if not present in resource

View File

@@ -251,7 +251,7 @@ class BaseModelRoutes(ABC):
def _find_model_file(self, files):
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
return next((file for file in files if file.get("type") == "Model" and file.get("primary") is True), None)
return next((file for file in files if file.get("type") in ("Model", "Diffusion Model") and file.get("primary") is True), None)
def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]:
"""Expose handlers for subclasses or tests."""

View File

@@ -33,15 +33,18 @@ from ...services.metadata_service import (
update_metadata_providers,
)
from ...services.service_registry import ServiceRegistry
from ...services.model_lifecycle_service import delete_model_artifacts
from ...services.settings_manager import get_settings_manager
from ...services.websocket_manager import ws_manager
from ...services.downloader import get_downloader
from ...services.errors import ResourceNotFoundError
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
from ...utils.models import BaseModelMetadata
from ...utils.constants import (
CIVITAI_USER_MODEL_TYPES,
DEFAULT_NODE_COLOR,
NODE_TYPES,
PREVIEW_EXTENSIONS,
SUPPORTED_MEDIA_EXTENSIONS,
VALID_LORA_TYPES,
)
@@ -617,6 +620,7 @@ class DoctorHandler:
diagnostics = [
await self._check_civitai_api_key(),
await self._check_cache_health(),
await self._check_filename_conflicts(),
self._check_ui_version(client_version, app_version),
]
@@ -681,6 +685,148 @@ class DoctorHandler:
status=status,
)
async def resolve_filename_conflicts(self, request: web.Request) -> web.Response:
if self._settings.get("lora_syntax_format", "legacy") == "full":
return web.json_response({"success": True, "renamed": [], "count": 0})
renamed: list[dict[str, Any]] = []
try:
for model_type, label, factory in self._scanner_factories:
try:
scanner = await factory()
hash_index = getattr(scanner, "_hash_index", None)
if hash_index is None:
continue
duplicates = {
filename: list(paths)
for filename, paths in hash_index.get_duplicate_filenames().items()
}
if not duplicates:
continue
cache = await scanner.get_cached_data()
path_to_model = {m["file_path"]: m for m in cache.raw_data}
used_basenames: set[str] = set()
for paths in duplicates.values():
if paths:
used_basenames.add(
os.path.splitext(os.path.basename(paths[0]))[0]
)
for filename, paths in duplicates.items():
for idx, path in enumerate(paths):
if idx == 0:
continue
dirname = os.path.dirname(path)
base_name = os.path.splitext(os.path.basename(path))[0]
ext = os.path.splitext(path)[1]
if not ext:
continue
model_data = path_to_model.get(path)
sha256 = (
model_data.get("sha256", "") if model_data else ""
)
hash_provider = (
lambda s=sha256: s if s else "0000"
)
new_filename = (
BaseModelMetadata.generate_unique_filename(
dirname,
base_name,
ext,
hash_provider=hash_provider,
)
)
candidate_base = os.path.splitext(new_filename)[0]
counter = 1
original_base = candidate_base
while candidate_base in used_basenames:
candidate_base = f"{original_base}-{counter}"
new_filename = f"{candidate_base}{ext}"
counter += 1
used_basenames.add(candidate_base)
new_path = os.path.join(dirname, new_filename)
if new_filename == os.path.basename(path):
continue
if not os.path.exists(path):
continue
old_base_no_ext = os.path.splitext(path)[0]
new_base_no_ext = (
os.path.splitext(new_path)[0]
)
os.rename(path, new_path)
for suffix in (".metadata.json", ".civitai.info"):
old_sidecar = old_base_no_ext + suffix
new_sidecar = new_base_no_ext + suffix
if os.path.exists(old_sidecar):
os.rename(old_sidecar, new_sidecar)
for preview_ext in PREVIEW_EXTENSIONS:
old_preview = old_base_no_ext + preview_ext
new_preview = new_base_no_ext + preview_ext
if os.path.exists(old_preview):
os.rename(old_preview, new_preview)
entry = path_to_model.get(path)
if entry:
entry = dict(entry)
entry["file_name"] = os.path.splitext(new_filename)[0]
if entry.get("preview_url"):
old_preview_url = entry["preview_url"].replace("\\", "/")
preview_ext = os.path.splitext(old_preview_url)[1]
if preview_ext:
entry["preview_url"] = (new_base_no_ext + preview_ext).replace(os.sep, "/")
await scanner.update_single_model_cache(
path, new_path, entry
)
logger.info(
"Resolved duplicate filename '%s': "
"renamed '%s' to '%s'",
filename,
path,
new_path,
)
renamed.append({
"model_type": model_type,
"label": label,
"filename": filename,
"old_path": path,
"new_path": new_path,
"new_filename": new_filename,
})
except Exception as exc: # pragma: no cover - defensive
logger.error(
"Failed to resolve filename conflicts for %s: %s",
model_type,
exc,
exc_info=True,
)
return web.json_response({
"success": True,
"renamed": renamed,
"count": len(renamed),
})
except Exception as exc:
logger.error(
"Error resolving filename conflicts: %s", exc, exc_info=True
)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
async def export_doctor_bundle(self, request: web.Request) -> web.Response:
try:
payload = await request.json()
@@ -846,6 +992,111 @@ class DoctorHandler:
"actions": [{"id": "repair-cache", "label": "Rebuild Cache"}],
}
async def _check_filename_conflicts(self) -> dict[str, Any]:
# When full path syntax is active, duplicate filenames across subfolders
# are not ambiguous (<lora:subfolder/name:strength>), so skip the check.
if self._settings.get("lora_syntax_format", "legacy") == "full":
return {
"id": "filename_conflicts",
"title": "Duplicate Filename Conflicts",
"status": "ok",
"summary": "Full path syntax is active — duplicate filenames across folders are not ambiguous.",
"details": [],
"actions": [],
}
all_conflicts: list[dict[str, Any]] = []
total_conflict_groups = 0
total_conflict_files = 0
for model_type, label, factory in self._scanner_factories:
# Duplicate filename detection targets LoRAs which use basename-only
# syntax (<lora:name:strength>). Checkpoints/embeddings reference
# models via relative paths with extensions, so conflicts there would
# be false positives.
if model_type != "lora":
continue
try:
scanner = await factory()
hash_index = getattr(scanner, "_hash_index", None)
if hash_index is None:
continue
duplicates = hash_index.get_duplicate_filenames()
if not duplicates:
continue
total_conflict_groups += len(duplicates)
for filename, paths in duplicates.items():
total_conflict_files += len(paths)
all_conflicts.append({
"model_type": model_type,
"label": label,
"filename": filename,
"paths": paths,
})
except Exception as exc: # pragma: no cover - defensive
logger.error(
"Doctor filename conflict check failed for %s: %s",
model_type,
exc,
exc_info=True,
)
if not all_conflicts:
return {
"id": "filename_conflicts",
"title": "Duplicate Filename Conflicts",
"status": "ok",
"summary": "No duplicate filenames found across model directories.",
"details": [],
"actions": [],
}
summary = (
f"{total_conflict_groups} filename(s) shared by "
f"{total_conflict_files} files across your library. "
f"This causes ambiguity when loading LoRAs by name."
)
details: list[str | dict[str, Any]] = [
{
"conflict_groups": total_conflict_groups,
"total_conflict_files": total_conflict_files,
}
]
# Show at most 5 conflict groups inline; note any remainder.
MAX_VISIBLE_CONFLICTS = 5
visible_conflicts = all_conflicts[:MAX_VISIBLE_CONFLICTS]
for conflict in visible_conflicts:
details.append(
f"'{conflict['filename']}' "
f"found in {len(conflict['paths'])} locations"
)
hidden_count = len(all_conflicts) - MAX_VISIBLE_CONFLICTS
if hidden_count > 0:
details.append(
f"...and {hidden_count} more duplicate filename group(s)"
)
return {
"id": "filename_conflicts",
"title": "Duplicate Filename Conflicts",
"status": "warning",
"summary": summary,
"details": details,
"actions": [
{
"id": "resolve-filename-conflicts",
"label": "Resolve Conflicts",
},
{
"id": "open-settings-syntax-format",
"label": "Switch to Full Path Syntax",
},
],
}
def _check_ui_version(self, client_version: str, app_version: str) -> dict[str, Any]:
if client_version and client_version != app_version:
return {
@@ -1576,29 +1827,33 @@ class ModelLibraryHandler:
exists = True
model_type = "embedding"
if exists:
return web.json_response(
{
"success": True,
"exists": True,
"modelType": model_type,
"hasBeenDownloaded": False,
}
)
history_service = await self._get_download_history_service()
has_been_downloaded = False
history_type = model_type
if history_type:
has_been_downloaded = await history_service.has_been_downloaded(
history_type,
history_type = None
for candidate_type in ("lora", "checkpoint", "embedding"):
if await history_service.has_been_downloaded(
candidate_type,
model_version_id,
)
else:
for candidate_type in ("lora", "checkpoint", "embedding"):
if await history_service.has_been_downloaded(
candidate_type,
model_version_id,
):
has_been_downloaded = True
history_type = candidate_type
break
):
has_been_downloaded = True
history_type = candidate_type
break
return web.json_response(
{
"success": True,
"exists": exists,
"modelType": model_type if exists else history_type,
"exists": False,
"modelType": history_type,
"hasBeenDownloaded": has_been_downloaded,
}
)
@@ -1618,40 +1873,46 @@ class ModelLibraryHandler:
model_type = None
versions = []
downloaded_version_ids = []
history_service = await self._get_download_history_service()
if lora_versions:
model_type = "lora"
versions = self._with_downloaded_flag(lora_versions)
downloaded_version_ids = await history_service.get_downloaded_version_ids(
model_type,
model_id,
return web.json_response(
{
"success": True,
"modelType": "lora",
"versions": self._with_downloaded_flag(lora_versions),
"downloadedVersionIds": [],
}
)
elif checkpoint_versions:
model_type = "checkpoint"
versions = self._with_downloaded_flag(checkpoint_versions)
downloaded_version_ids = await history_service.get_downloaded_version_ids(
model_type,
model_id,
if checkpoint_versions:
return web.json_response(
{
"success": True,
"modelType": "checkpoint",
"versions": self._with_downloaded_flag(checkpoint_versions),
"downloadedVersionIds": [],
}
)
elif embedding_versions:
model_type = "embedding"
versions = self._with_downloaded_flag(embedding_versions)
downloaded_version_ids = await history_service.get_downloaded_version_ids(
model_type,
model_id,
if embedding_versions:
return web.json_response(
{
"success": True,
"modelType": "embedding",
"versions": self._with_downloaded_flag(embedding_versions),
"downloadedVersionIds": [],
}
)
else:
for candidate_type in ("lora", "checkpoint", "embedding"):
candidate_downloaded_version_ids = (
await history_service.get_downloaded_version_ids(
candidate_type,
model_id,
)
history_service = await self._get_download_history_service()
for candidate_type in ("lora", "checkpoint", "embedding"):
candidate_downloaded_version_ids = (
await history_service.get_downloaded_version_ids(
candidate_type,
model_id,
)
if candidate_downloaded_version_ids:
model_type = candidate_type
downloaded_version_ids = candidate_downloaded_version_ids
break
)
if candidate_downloaded_version_ids:
model_type = candidate_type
downloaded_version_ids = candidate_downloaded_version_ids
break
return web.json_response(
{
@@ -1665,6 +1926,86 @@ class ModelLibraryHandler:
logger.error("Failed to check model existence: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def check_models_exist(self, request: web.Request) -> web.Response:
try:
model_ids_raw = request.query.get("modelIds", "")
if not model_ids_raw:
return web.json_response(
{"success": True, "results": []}
)
raw_ids = model_ids_raw.split(",")
seen: set[int] = set()
model_ids: list[int] = []
for raw in raw_ids:
stripped = raw.strip()
if not stripped:
continue
try:
mid = int(stripped)
except ValueError:
continue
if mid not in seen:
seen.add(mid)
model_ids.append(mid)
if not model_ids:
return web.json_response(
{"success": True, "results": []}
)
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner()
results: list[dict] = []
for model_id in model_ids:
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
if lora_versions:
results.append({
"modelId": model_id,
"modelType": "lora",
"versions": self._with_downloaded_flag(lora_versions),
"downloadedVersionIds": [],
})
continue
if checkpoint_scanner:
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(model_id)
if checkpoint_versions:
results.append({
"modelId": model_id,
"modelType": "checkpoint",
"versions": self._with_downloaded_flag(checkpoint_versions),
"downloadedVersionIds": [],
})
continue
if embedding_scanner:
embedding_versions = await embedding_scanner.get_model_versions_by_id(model_id)
if embedding_versions:
results.append({
"modelId": model_id,
"modelType": "embedding",
"versions": self._with_downloaded_flag(embedding_versions),
"downloadedVersionIds": [],
})
continue
results.append({
"modelId": model_id,
"modelType": None,
"versions": [],
"downloadedVersionIds": [],
})
return web.json_response(
{"success": True, "results": results}
)
except Exception as exc:
logger.error("Failed to check models existence: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_model_version_download_status(
self, request: web.Request
) -> web.Response:
@@ -1759,7 +2100,7 @@ class ModelLibraryHandler:
file_path=file_path if isinstance(file_path, str) else None,
)
else:
await history_service.mark_not_downloaded(model_type, model_version_id)
await history_service.mark_as_deleted(model_type, model_version_id)
return web.json_response(
{
@@ -1777,6 +2118,89 @@ class ModelLibraryHandler:
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def delete_model_version(self, request: web.Request) -> web.Response:
try:
model_version_id_str = request.query.get("modelVersionId")
if not model_version_id_str:
return web.json_response(
{"success": False, "error": "Missing required parameter: modelVersionId"},
status=400,
)
try:
model_version_id = int(model_version_id_str)
except ValueError:
return web.json_response(
{"success": False, "error": "Parameter modelVersionId must be an integer"},
status=400,
)
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner()
found_type = None
file_path = None
found_cache = None
for model_type, scanner in (
("lora", lora_scanner),
("checkpoint", checkpoint_scanner),
("embedding", embedding_scanner),
):
cache = await scanner.get_cached_data()
if cache and model_version_id in cache.version_index:
found_type = model_type
found_cache = cache
entry = cache.version_index[model_version_id]
file_path = entry.get("file_path")
break
if not file_path:
return web.json_response(
{"success": False, "error": "Model version not found in any scanner cache"},
status=404,
)
target_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path)
file_name, extension = os.path.splitext(base_name)
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
if found_cache:
found_cache.raw_data = [
item
for item in found_cache.raw_data
if item.get("file_path") != file_path
]
await found_cache.resort()
scanner_map = {
"lora": lora_scanner,
"checkpoint": checkpoint_scanner,
"embedding": embedding_scanner,
}
scanner = scanner_map.get(found_type)
if scanner:
persist = getattr(scanner, "_persist_current_cache", None)
if callable(persist):
await persist()
history_service = await self._get_download_history_service()
await history_service.mark_as_deleted(found_type, model_version_id)
return web.json_response(
{
"success": True,
"modelType": found_type,
"modelVersionId": model_version_id,
}
)
except Exception as exc:
logger.error(
"Failed to delete model version: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_model_versions_status(self, request: web.Request) -> web.Response:
try:
model_id_str = request.query.get("modelId")
@@ -2796,6 +3220,7 @@ class MiscHandlerSet:
"update_settings": self.settings.update_settings,
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
"repair_doctor_cache": self.doctor.repair_doctor_cache,
"resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts,
"export_doctor_bundle": self.doctor.export_doctor_bundle,
"get_priority_tags": self.settings.get_priority_tags,
"get_settings_libraries": self.settings.get_libraries,
@@ -2809,8 +3234,10 @@ class MiscHandlerSet:
"update_node_widget": self.node_registry.update_node_widget,
"get_registry": self.node_registry.get_registry,
"check_model_exists": self.model_library.check_model_exists,
"check_models_exist": self.model_library.check_models_exist,
"get_model_version_download_status": self.model_library.get_model_version_download_status,
"set_model_version_download_status": self.model_library.set_model_version_download_status,
"delete_model_version": self.model_library.delete_model_version,
"get_civitai_user_models": self.model_library.get_civitai_user_models,
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,

View File

@@ -301,6 +301,15 @@ class ModelListingHandler:
for tag in exclude_tags:
if tag:
tag_filters[tag] = "exclude"
auto_tag_filters: Dict[str, str] = {}
for tag in request.query.getall("auto_tag_include", []):
if tag:
auto_tag_filters[tag] = "include"
for tag in request.query.getall("auto_tag_exclude", []):
if tag:
auto_tag_filters[tag] = "exclude"
favorites_only = request.query.get("favorites_only", "false").lower() == "true"
search_options = {
@@ -367,6 +376,7 @@ class ModelListingHandler:
"fuzzy_search": fuzzy_search,
"base_models": base_models,
"tags": tag_filters,
"auto_tags": auto_tag_filters,
"tag_logic": tag_logic,
"search_options": search_options,
"hash_filters": hash_filters,
@@ -778,7 +788,7 @@ class ModelManagementHandler:
metadata_updates = {k: v for k, v in data.items() if k != "file_path"}
await self._metadata_sync.save_metadata_updates(
updated_metadata = await self._metadata_sync.save_metadata_updates(
file_path=file_path,
updates=metadata_updates,
metadata_loader=self._metadata_sync.load_local_metadata,
@@ -789,7 +799,12 @@ class ModelManagementHandler:
cache = await self._service.scanner.get_cached_data()
await cache.resort()
return web.json_response({"success": True})
from ...services.auto_tag_service import extract_auto_tags
auto_tags = extract_auto_tags(updated_metadata)
return web.json_response(
{"success": True, "auto_tags": auto_tags}
)
except Exception as exc:
self._logger.error("Error saving metadata: %s", exc, exc_info=True)
return web.Response(text=str(exc), status=500)
@@ -806,14 +821,16 @@ class ModelManagementHandler:
if not isinstance(new_tags, list):
return web.Response(text="Tags must be a list", status=400)
tags = await self._tag_update_service.add_tags(
tags, auto_tags = await self._tag_update_service.add_tags(
file_path=file_path,
new_tags=new_tags,
metadata_loader=self._metadata_sync.load_local_metadata,
update_cache=self._service.scanner.update_single_model_cache,
)
return web.json_response({"success": True, "tags": tags})
return web.json_response(
{"success": True, "tags": tags, "auto_tags": auto_tags}
)
except Exception as exc:
self._logger.error("Error adding tags: %s", exc, exc_info=True)
return web.Response(text=str(exc), status=500)
@@ -1160,6 +1177,12 @@ class ModelQueryHandler:
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
try:
settings = get_settings_manager()
if settings.get("lora_syntax_format", "legacy") == "full":
return web.json_response(
{"success": True, "conflicts": [], "count": 0}
)
duplicates = self._service.find_duplicate_filenames()
result = []
cache = await self._service.scanner.get_cached_data()
@@ -2423,6 +2446,7 @@ class ModelUpdateHandler:
"shouldIgnore": version.should_ignore,
"earlyAccessEndsAt": version.early_access_ends_at,
"isEarlyAccess": is_early_access,
"usageControl": version.usage_control,
"filePath": context.get("file_path"),
"fileName": context.get("file_name"),
}

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import mimetypes
import urllib.parse
from pathlib import Path
@@ -12,6 +13,12 @@ from ...config import config as global_config
logger = logging.getLogger(__name__)
_CHUNK_SIZE = 256 * 1024 # 256 KB
# Video file extensions that bypass native sendfile on Windows
# to avoid IOCP/ProactorEventLoop crashes during client disconnect.
_VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".mov", ".avi", ".mkv"})
class PreviewHandler:
"""Serve preview assets for the active library at request time."""
@@ -48,8 +55,51 @@ class PreviewHandler:
logger.debug("Preview file not found at %s", str(resolved))
raise web.HTTPNotFound(text="Preview file not found")
# Video files: stream manually to avoid Windows native sendfile crash.
# aiohttp's FileResponse uses _sendfile_native on Windows (IOCP-based),
# which breaks when the client disconnects mid-transfer — this happens
# constantly when users scroll through a gallery of animated previews.
suffix = resolved.suffix.lower()
if suffix in _VIDEO_EXTENSIONS:
return await self._stream_file(request, resolved)
# aiohttp's FileResponse handles range requests and content headers for us.
return web.FileResponse(path=resolved, chunk_size=256 * 1024)
return web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
async def _stream_file(
self, request: web.Request, path: Path
) -> web.StreamResponse:
"""Stream a file chunk-by-chunk, bypassing native sendfile.
This avoids the Windows IOCP ``_sendfile_native`` crash that occurs
when the client disconnects during a large file transfer.
"""
content_type, _ = mimetypes.guess_type(str(path))
if content_type is None:
content_type = "application/octet-stream"
file_size = path.stat().st_size
resp = web.StreamResponse()
resp.content_type = content_type
resp.content_length = file_size
await resp.prepare(request)
try:
with open(path, "rb") as f:
while True:
chunk = f.read(_CHUNK_SIZE)
if not chunk:
break
await resp.write(chunk)
except (ConnectionResetError, ConnectionAbortedError):
# Client disconnected during streaming — expected when scrolling
# rapidly through a library with animated previews.
pass
except OSError as exc:
logger.debug("I/O error streaming preview %s: %s", path, exc)
return resp
__all__ = ["PreviewHandler"]

View File

@@ -87,12 +87,15 @@ class RecipeHandlerSet:
"repair_recipes": self.management.repair_recipes,
"cancel_repair": self.management.cancel_repair,
"repair_recipe": self.management.repair_recipe,
"repair_recipes_bulk": self.management.repair_recipes_bulk,
"get_repair_progress": self.management.get_repair_progress,
"start_batch_import": self.batch_import.start_batch_import,
"get_batch_import_progress": self.batch_import.get_batch_import_progress,
"cancel_batch_import": self.batch_import.cancel_batch_import,
"start_directory_import": self.batch_import.start_directory_import,
"browse_directory": self.batch_import.browse_directory,
"check_image_exists": self.management.check_image_exists,
"import_from_url": self.management.import_from_url,
}
@@ -541,7 +544,7 @@ class RecipeQueryHandler:
)
response_data.append(
{
"type": "source_url",
"type": "source_path",
"fingerprint": url,
"count": len(recipes),
"recipes": recipes,
@@ -607,6 +610,7 @@ class RecipeManagementHandler:
self._downloader_factory = downloader_factory
self._civitai_client_getter = civitai_client_getter
self._ws_manager = ws_manager
self._import_semaphore = asyncio.Semaphore(2)
async def save_recipe(self, request: web.Request) -> web.Response:
try:
@@ -703,6 +707,69 @@ class RecipeManagementHandler:
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def repair_recipes_bulk(self, request: web.Request) -> web.Response:
"""Bulk repair metadata for multiple recipes by their IDs.
Accepts a JSON body with a "recipe_ids" array and iterates
repair_recipe_by_id over each entry, collecting statistics.
"""
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
data = await request.json()
recipe_ids = data.get("recipe_ids", [])
if not recipe_ids:
return web.json_response(
{"success": False, "error": "recipe_ids are required"},
status=400,
)
total = len(recipe_ids)
repaired = 0
skipped = 0
errors = 0
recipes = []
for recipe_id in recipe_ids:
try:
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
if result.get("success"):
repaired += result.get("repaired", 0)
skipped += result.get("skipped", 0)
if result.get("recipe"):
recipes.append(result["recipe"])
else:
errors += 1
except RecipeNotFoundError:
skipped += 1
except Exception as exc:
self._logger.error(
"Error repairing recipe %s: %s", recipe_id, exc
)
errors += 1
return web.json_response({
"success": True,
"total": total,
"repaired": repaired,
"skipped": skipped,
"errors": errors,
"recipes": recipes,
})
except Exception as exc:
self._logger.error(
"Error performing bulk repair: %s", exc, exc_info=True
)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
async def repair_recipe(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
@@ -760,125 +827,28 @@ class RecipeManagementHandler:
gen_params_request = self._parse_gen_params(params.get("gen_params"))
self._logger.info(
"Remote recipe import received: url=%s, request_gen_params_keys=%s, lora_count=%d, checkpoint_keys=%s",
"Remote recipe import received: url=%s, lora_count=%d",
image_url,
sorted(gen_params_request.keys()) if gen_params_request else [],
len(lora_entries),
)
self._logger.debug(
" gen_params_keys=%s, checkpoint_keys=%s",
sorted(gen_params_request.keys()) if gen_params_request else [],
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
)
# 2. Initial Metadata Construction
metadata: Dict[str, Any] = {
"base_model": params.get("base_model", "") or "",
"loras": lora_entries,
"gen_params": gen_params_request or {},
"source_url": image_url,
}
source_path = params.get("source_path")
if source_path:
metadata["source_path"] = source_path
# Checkpoint handling
if checkpoint_entry:
metadata["checkpoint"] = checkpoint_entry
# Ensure checkpoint is also in gen_params for consistency if needed by enricher?
# Actually enricher looks at metadata['checkpoint'], so this is fine.
# Try to resolve base model from checkpoint if not explicitly provided
if not metadata["base_model"]:
base_model_from_metadata = (
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
)
if base_model_from_metadata:
metadata["base_model"] = base_model_from_metadata
tags = self._parse_tags(params.get("tags"))
# 3. Download Image
(
image_bytes,
extension,
civitai_meta_from_download,
) = await self._download_remote_media(image_url)
# 4. Extract Embedded Metadata
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
# with embedded data if we want it to merge it.
# However, logic in Enricher merges: request > civitai > embedded.
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
# OR pass them to enricher to handle?
# The interface of Enricher.enrich_recipe takes `recipe` (with gen_params) and `request_params`.
# So let's extract embedded and put it into recipe['gen_params'] but careful not to overwrite request params.
# Actually, `GenParamsMerger` which `Enricher` uses handles 3 layers.
# But `Enricher` interface is: recipe['gen_params'] (as embedded) + request_params + civitai (fetched internally).
# Wait, `Enricher` fetches Civitai info internally based on URL.
# `civitai_meta_from_download` is returned by `_download_remote_media` which might be useful if URL didn't have ID.
# Let's extract embedded metadata first
embedded_gen_params = {}
try:
with tempfile.NamedTemporaryFile(
suffix=extension, delete=False
) as temp_img:
temp_img.write(image_bytes)
temp_img_path = temp_img.name
try:
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
if raw_embedded:
parser = (
self._analysis_service._recipe_parser_factory.create_parser(
raw_embedded
)
)
if parser:
parsed_embedded = await parser.parse_metadata(
raw_embedded, recipe_scanner=recipe_scanner
)
if parsed_embedded and "gen_params" in parsed_embedded:
embedded_gen_params = parsed_embedded["gen_params"]
else:
embedded_gen_params = {"raw_metadata": raw_embedded}
finally:
if os.path.exists(temp_img_path):
os.unlink(temp_img_path)
except Exception as exc:
self._logger.warning(
"Failed to extract embedded metadata during import: %s", exc
# Throttle concurrent imports to avoid starving ComfyUI's event loop
async with self._import_semaphore:
return await self._do_import_remote_recipe(
image_url=image_url,
name=name,
lora_entries=lora_entries,
checkpoint_entry=checkpoint_entry,
gen_params_request=gen_params_request,
tags=self._parse_tags(params.get("tags")),
base_model=params.get("base_model", "") or "",
source_path=params.get("source_path") or image_url,
)
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer
if embedded_gen_params:
# Merge embedded into existing gen_params (which currently only has request params if any)
# But wait, we want request params to override everything.
# So we should set recipe['gen_params'] = embedded, and pass request params to enricher.
metadata["gen_params"] = embedded_gen_params
# 5. Enrich with unified logic
# This will fetch Civitai info (if URL matches) and merge: request > civitai > embedded
civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe(
recipe=metadata,
civitai_client=civitai_client,
request_params=gen_params_request, # Pass explicit request params here to override
)
# If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed),
# we might want to manually merge it?
# But usually `import_remote_recipe` is used with Civitai URLs.
# For now, relying on Enricher's internal fetch is consistent with repair.
result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner,
image_bytes=image_bytes,
image_base64=None,
name=name,
tags=tags,
metadata=metadata,
extension=extension,
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeDownloadError as exc:
@@ -889,6 +859,150 @@ class RecipeManagementHandler:
)
return web.json_response({"error": str(exc)}, status=500)
async def _do_import_remote_recipe(
self,
*,
image_url: str,
name: str,
lora_entries: list,
checkpoint_entry: dict,
gen_params_request: dict,
tags: list,
base_model: str,
source_path: str,
) -> web.Response:
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
metadata: Dict[str, Any] = {
"base_model": base_model,
"loras": lora_entries,
"gen_params": gen_params_request or {},
"source_path": source_path,
}
if checkpoint_entry:
metadata["checkpoint"] = checkpoint_entry
if not metadata["base_model"]:
base_model_from_metadata = (
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
)
if base_model_from_metadata:
metadata["base_model"] = base_model_from_metadata
# Download image
(
image_bytes,
extension,
civitai_meta_raw,
model_version_id,
) = await self._download_remote_media(image_url)
# Extract embedded EXIF metadata (offloaded to thread pool in this call)
embedded_gen_params = {}
parsed_embedded = None
try:
with tempfile.NamedTemporaryFile(
suffix=extension, delete=False
) as temp_img:
temp_img.write(image_bytes)
temp_img_path = temp_img.name
try:
raw_embedded = await asyncio.to_thread(
ExifUtils.extract_image_metadata, temp_img_path
)
if raw_embedded:
parser = (
self._analysis_service._recipe_parser_factory.create_parser(
raw_embedded
)
)
if parser:
parsed_embedded = await parser.parse_metadata(
raw_embedded, recipe_scanner=recipe_scanner
)
if parsed_embedded and "gen_params" in parsed_embedded:
embedded_gen_params = parsed_embedded["gen_params"]
else:
embedded_gen_params = {"raw_metadata": raw_embedded}
finally:
if os.path.exists(temp_img_path):
os.unlink(temp_img_path)
except Exception as exc:
self._logger.warning(
"Failed to extract embedded metadata during import: %s", exc
)
# Parse CivitAI API meta to discover all resources from modelVersionIds
# (modelVersionIds is injected at root level by _download_remote_media).
# Run unconditionally — EXIF parsing may succeed for gen_params but miss
# LoRAs since modelVersionIds is NOT embedded in the image EXIF.
civitai_parsed = None
if civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw["meta"]
# modelVersionIds lives at outer meta level; propagate after unwrap
_mvids = civitai_meta_raw.get("modelVersionIds")
if _mvids and isinstance(civitai_inner_meta, dict):
civitai_inner_meta["modelVersionIds"] = _mvids
if isinstance(civitai_inner_meta, dict):
parser = self._analysis_service._recipe_parser_factory.create_parser(
civitai_inner_meta
)
if parser:
civitai_parsed = await parser.parse_metadata(
civitai_inner_meta, recipe_scanner=recipe_scanner
)
if civitai_parsed and "gen_params" in civitai_parsed:
# Merge: API gen_params override EXIF at field level,
# EXIF fills in fields the API doesn't have.
embedded_gen_params = {
**(embedded_gen_params or {}),
**civitai_parsed["gen_params"],
}
if embedded_gen_params:
metadata["gen_params"] = embedded_gen_params
# Merge LoRAs: prefer frontend resources, supplement with CivitAI modelVersionIds
if civitai_parsed:
civitai_loras = civitai_parsed.get("loras", [])
if civitai_loras and not metadata.get("loras"):
metadata["loras"] = civitai_loras
civitai_model = civitai_parsed.get("model")
if civitai_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = civitai_model
elif parsed_embedded:
parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"):
metadata["loras"] = parsed_loras
parsed_model = parsed_embedded.get("model")
if parsed_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = parsed_model
civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe(
recipe=metadata,
civitai_client=civitai_client,
request_params=gen_params_request,
prefetched_civitai_meta_raw=civitai_meta_raw,
prefetched_model_version_id=model_version_id,
)
result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner,
image_bytes=image_bytes,
image_base64=None,
name=name,
tags=tags,
metadata=metadata,
extension=extension,
)
return web.json_response(result.payload, status=result.status)
async def delete_recipe(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
@@ -1190,7 +1304,7 @@ class RecipeManagementHandler:
"exclude": False,
}
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any]:
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any, Any]:
civitai_client = self._civitai_client_getter()
downloader = await self._downloader_factory()
temp_path = None
@@ -1238,10 +1352,38 @@ class RecipeManagementHandler:
extension = ".webp" # Default to webp if unknown
with open(temp_path, "rb") as file_obj:
model_ver_id = None
civitai_meta_raw = (
image_info.get("meta") if civitai_image_id and image_info else None
)
if civitai_image_id and image_info:
# modelVersionId (singular) — the primary version for this
# image on CivitAI. May be absent, or may *not* be the
# checkpoint (e.g. when the image was generated with a LoRA
# as the primary subject). When absent, DO NOT fall back to
# modelVersionIds[0] — that array mixes checkpoints, LoRAs,
# and other model version IDs without ordering guarantees.
# The downstream enrichment flow will find the real
# checkpoint via meta.resources (type:"model" hash) or
# meta.civitaiResources (type:"checkpoint" version ID), so
# leaving model_ver_id as None is safe and avoids the bug
# where a LoRA version ID was treated as the checkpoint.
model_ver_id = image_info.get("modelVersionId")
# Inject root-level modelVersionIds into meta so downstream
# parsers (CivitaiApiMetadataParser) can discover ALL resources
# (checkpoint + LoRAs), not just the first model version ID.
# 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
return (
file_obj.read(),
extension,
image_info.get("meta") if civitai_image_id and image_info else None,
civitai_meta_raw,
model_ver_id,
)
except RecipeDownloadError:
raise
@@ -1289,6 +1431,226 @@ class RecipeManagementHandler:
return ""
async def check_image_exists(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
image_ids_raw = request.query.get("image_ids", "")
if not image_ids_raw:
return web.json_response({"success": True, "results": {}})
requested_ids = set()
for raw in image_ids_raw.split(","):
stripped = raw.strip()
if stripped and stripped.isdigit():
requested_ids.add(stripped)
if not requested_ids:
return web.json_response({"success": True, "results": {}})
cache = await recipe_scanner.get_cached_data()
# Build lookup: image_id -> recipe_id from stored source_path
image_to_recipe = {}
for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path")
if not source:
continue
image_id = extract_civitai_image_id(source)
if image_id and image_id not in image_to_recipe:
image_to_recipe[image_id] = recipe.get("id")
results = {}
for img_id in requested_ids:
recipe_id = image_to_recipe.get(img_id)
results[img_id] = {
"in_library": recipe_id is not None,
"recipe_id": recipe_id,
}
return web.json_response({"success": True, "results": results})
except Exception as exc:
self._logger.error(
"Error checking image existence: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def import_from_url(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
image_url = request.query.get("image_url")
if not image_url:
raise RecipeValidationError("Missing required field: image_url")
image_id = extract_civitai_image_id(image_url)
if not image_id:
raise RecipeValidationError(
"Could not extract Civitai image ID from URL"
)
# Check for duplicate (fast, before acquiring semaphore)
cache = await recipe_scanner.get_cached_data()
for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path")
if source:
existing_id = extract_civitai_image_id(source)
if existing_id == image_id:
return web.json_response({
"success": True,
"recipe_id": recipe.get("id"),
"name": recipe.get("title", ""),
"already_exists": True,
})
async with self._import_semaphore:
return await self._do_import_from_url(image_url, recipe_scanner)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeDownloadError as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
self._logger.error(
"Error importing recipe from URL: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def _do_import_from_url(
self,
image_url: str,
recipe_scanner: Any,
) -> web.Response:
image_id = extract_civitai_image_id(image_url)
if not image_id:
raise RecipeValidationError(
"Could not extract Civitai image ID from URL"
)
image_bytes, extension, civitai_meta_raw, model_version_id = (
await self._download_remote_media(image_url)
)
# Extract embedded EXIF metadata
embedded_gen_params = {}
parsed_embedded = None
try:
with tempfile.NamedTemporaryFile(
suffix=extension, delete=False
) as temp_img:
temp_img.write(image_bytes)
temp_img_path = temp_img.name
try:
raw_embedded = await asyncio.to_thread(
ExifUtils.extract_image_metadata, temp_img_path
)
if raw_embedded:
parser = (
self._analysis_service._recipe_parser_factory.create_parser(
raw_embedded
)
)
if parser:
parsed_embedded = await parser.parse_metadata(
raw_embedded, recipe_scanner=recipe_scanner
)
if parsed_embedded and "gen_params" in parsed_embedded:
embedded_gen_params = parsed_embedded["gen_params"]
finally:
if os.path.exists(temp_img_path):
os.unlink(temp_img_path)
except Exception as exc:
self._logger.warning(
"Failed to extract embedded metadata: %s", exc
)
# Parse CivitAI API meta to discover all resources from modelVersionIds.
# Run unconditionally — EXIF parsing succeeds for gen_params but misses
# LoRAs (modelVersionIds is NOT in the image EXIF).
civitai_parsed = None
if civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw["meta"]
# Propagate modelVersionIds into unwrapped meta — it lives
# at the outer meta level in the CivitAI API response.
_mvids = civitai_meta_raw.get("modelVersionIds")
if _mvids and isinstance(civitai_inner_meta, dict):
civitai_inner_meta["modelVersionIds"] = _mvids
if isinstance(civitai_inner_meta, dict):
parser = self._analysis_service._recipe_parser_factory.create_parser(
civitai_inner_meta
)
if parser:
civitai_parsed = await parser.parse_metadata(
civitai_inner_meta, recipe_scanner=recipe_scanner
)
if civitai_parsed and "gen_params" in civitai_parsed:
# Merge: API gen_params override EXIF at field level,
# EXIF fills in fields the API doesn't have.
embedded_gen_params = {
**(embedded_gen_params or {}),
**civitai_parsed["gen_params"],
}
metadata: Dict[str, Any] = {
"base_model": "",
"loras": [],
"gen_params": embedded_gen_params or {},
"source_path": image_url,
}
if civitai_parsed:
civitai_loras = civitai_parsed.get("loras", [])
if civitai_loras and not metadata.get("loras"):
metadata["loras"] = civitai_loras
civitai_model = civitai_parsed.get("model")
if civitai_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = civitai_model
elif parsed_embedded:
parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"):
metadata["loras"] = parsed_loras
parsed_model = parsed_embedded.get("model")
if parsed_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = parsed_model
civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe(
recipe=metadata,
civitai_client=civitai_client,
request_params={},
prefetched_civitai_meta_raw=civitai_meta_raw,
prefetched_model_version_id=model_version_id,
)
prompt = (
metadata.get("gen_params", {}).get("prompt")
or metadata.get("gen_params", {}).get("positivePrompt")
or ""
)
if prompt:
name = " ".join(str(prompt).split()[:10])
else:
name = f"Civitai Image {image_id}"
result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner,
image_bytes=image_bytes,
image_base64=None,
name=name,
tags=[],
metadata=metadata,
extension=extension,
)
return web.json_response(result.payload, status=result.status)
class RecipeAnalysisHandler:
"""Analyze images to extract recipe metadata."""

View File

@@ -24,6 +24,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"),
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
@@ -42,6 +43,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
RouteDefinition(
"GET",
"/api/lm/model-version-download-status",
@@ -89,6 +91,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
),
RouteDefinition(
"GET", "/api/lm/delete-model-version", "delete_model_version"
),
)

View File

@@ -58,6 +58,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
RouteDefinition(
@@ -70,6 +71,10 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
),
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"),
RouteDefinition(
"GET", "/api/lm/recipes/check-image-exists", "check_image_exists"
),
RouteDefinition("GET", "/api/lm/recipes/import-from-url", "import_from_url"),
)

View File

@@ -39,7 +39,7 @@ class Aria2Transfer:
class Aria2Downloader:
"""Manage an aria2 RPC daemon for experimental model downloads."""
"""Manage an aria2 RPC daemon for recommended model downloads."""
_instance = None
_lock = asyncio.Lock()

View File

@@ -0,0 +1,139 @@
"""
Auto-tag extraction service for model cards.
Extracts implicit model attributes (HIGH/LOW, I2V/T2V/TI2V, Lightning, Turbo)
from filename, base_model, and CivitAI version name — no manual tagging required.
"""
from __future__ import annotations
import re
from typing import Dict, List, Set
# ── Tag category definitions ──────────────────────────────────────────
# Each category maps a display label to a regex pattern.
# Patterns are case-insensitive and matched against filename, base_model,
# and civitai version name.
# Use (?<![a-zA-Z0-9]) and (?![a-zA-Z0-9]) instead of \b because
# Python's \b treats underscore as a word character, so \bHIGH\b
# won't match '_HIGH_' in filenames.
_B = r"(?<![a-zA-Z0-9])" # left boundary
_E = r"(?![a-zA-Z0-9])" # right boundary
AUTO_TAG_CATEGORIES: Dict[str, str] = {
"HIGH": _B + r"HIGH" + _E,
"LOW": _B + r"(?<!F)LOW" + _E,
"I2V": _B + r"I2V" + _E,
"T2V": _B + r"T2V" + _E,
"TI2V": _B + r"TI2V" + _E,
"Lightning": _B + r"Lightning" + _E,
"Turbo": _B + r"Turbo" + _E,
}
# Tags that belong to the "mode" group (HIGH/LOW)
MODE_TAGS = {"HIGH", "LOW"}
# Tags that belong to the "video mode" group (I2V/T2V/TI2V)
VIDEO_MODE_TAGS = {"I2V", "T2V", "TI2V"}
# Tags that belong to the "speed/optimization" group
SPEED_TAGS = {"Lightning", "Turbo"}
# ── Display category groups (for settings UI) ─────────────────────────
AUTO_TAG_GROUPS = {
"mode": {"HIGH", "LOW"},
"video": {"I2V", "T2V", "TI2V"},
"speed": {"Lightning", "Turbo"},
}
# Default enabled categories
DEFAULT_ENABLED_GROUPS = {"mode", "video"}
def _collect_sources(model_data: Dict) -> List[str]:
"""Collect all text sources from model data for tag matching."""
sources: List[str] = []
file_name = model_data.get("file_name", "")
if file_name:
sources.append(file_name)
base_model = model_data.get("base_model", "")
if base_model:
sources.append(base_model)
civitai = model_data.get("civitai", {})
if isinstance(civitai, dict):
version_name = civitai.get("name", "")
if version_name:
sources.append(version_name)
return sources
def extract_auto_tags(model_data: Dict) -> List[str]:
"""Extract auto-detected tags from model metadata.
Uses a two-layer approach:
Layer 1 — Regex-based detection against filename, base_model, and
CivitAI version name.
Layer 2 — Merge in any user-defined tags that overlap with known
auto-tag categories. This provides a manual fallback when
auto-detection fails (e.g. "I2V HN" or unlabeled models).
HIGH/LOW tags are only returned when the base_model indicates a Wan
family model — no other model architecture uses this distinction.
Args:
model_data: Model metadata dict with keys:
file_name, base_model, civitai (with optional 'name' field),
tags (user-defined tag list, used as fallback).
Returns:
Sorted list of unique auto-tag strings (e.g. ["I2V"]).
"""
sources = _collect_sources(model_data)
base_model = model_data.get("base_model", "")
is_wan = "wan" in base_model.lower()
found: Set[str] = set()
# ── Layer 1: regex-based detection ────────────────────────────
if sources:
for label, pattern in AUTO_TAG_CATEGORIES.items():
# HIGH/LOW are Wan-specific — skip for non-Wan to avoid noise
if label in ("HIGH", "LOW"):
if not is_wan:
continue
# Use case-insensitive character class + case-sensitive boundary,
# so "HighNoise" (camelCase) matches but "highlight" doesn't.
# Boundary: not followed by lowercase letter (= word has ended).
ci = "".join(f"[{c.lower()}{c.upper()}]" for c in label)
if label == "LOW":
regex = re.compile(r"(?<![Ff])" + ci + r"(?![a-z])")
else:
regex = re.compile(ci + r"(?![a-z])")
else:
regex = re.compile(pattern, re.IGNORECASE)
for source in sources:
if regex.search(source):
found.add(label)
break
# ── Layer 2: user-defined tags as manual fallback ─────────────
# When auto-detection fails (abbreviated names like "Hi"/"Lo",
# "I2V HN", or unlabeled models), users can add canonical tags
# (HIGH, LOW, I2V, etc.) to the model's regular tags for correct
# badge display and filtering. Matching is case-insensitive so
# "high"/"High"/"HIGH" all resolve to the canonical label.
user_tags = model_data.get("tags")
if user_tags:
label_map = {label.lower(): label for label in AUTO_TAG_CATEGORIES}
for t in user_tags:
canonical = label_map.get(t.lower())
if canonical:
found.add(canonical)
return sorted(found)

View File

@@ -77,6 +77,7 @@ class BaseModelService(ABC):
base_models: list = None,
model_types: list = None,
tags: Optional[Dict[str, str]] = None,
auto_tags: Optional[Dict[str, str]] = None,
search_options: dict = None,
hash_filters: dict = None,
favorites_only: bool = False,
@@ -95,6 +96,11 @@ class BaseModelService(ABC):
sorted_data = await self._fetch_with_usage_sort(sort_params)
else:
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
# Pre-compute auto_tags for every item — needed for both filtering
# and display. Computation is cheap (string regex on 2-3 fields).
from .auto_tag_service import extract_auto_tags
for item in sorted_data:
item["auto_tags"] = extract_auto_tags(item)
fetch_duration = time.perf_counter() - t0
initial_count = len(sorted_data)
@@ -110,6 +116,7 @@ class BaseModelService(ABC):
base_models=base_models,
model_types=model_types,
tags=tags,
auto_tags=auto_tags,
favorites_only=favorites_only,
search_options=search_options,
tag_logic=tag_logic,
@@ -354,6 +361,7 @@ class BaseModelService(ABC):
base_models: list = None,
model_types: list = None,
tags: Optional[Dict[str, str]] = None,
auto_tags: Optional[Dict[str, str]] = None,
favorites_only: bool = False,
search_options: dict = None,
tag_logic: str = "any",
@@ -367,6 +375,7 @@ class BaseModelService(ABC):
base_models=base_models,
model_types=model_types,
tags=tags,
auto_tags=auto_tags,
favorites_only=favorites_only,
search_options=normalized_options,
tag_logic=tag_logic,
@@ -861,22 +870,75 @@ class BaseModelService(ABC):
"""Get the static preview URL for a model file"""
cache = await self.scanner.get_cached_data()
name_normalized = model_name.replace("\\", "/")
name_no_ext = name_normalized
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if name_no_ext.lower().endswith(ext):
name_no_ext = name_no_ext[: -len(ext)]
break
has_path = "/" in name_no_ext
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
best_fallback = None
for model in cache.raw_data:
if model["file_name"] == model_name:
file_name = model.get("file_name", "")
folder = model.get("folder", "")
file_name_no_ext = file_name
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if file_name_no_ext.lower().endswith(ext):
file_name_no_ext = file_name_no_ext[: -len(ext)]
break
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
preview_url = model.get("preview_url")
if preview_url:
from ..config import config
return config.get_preview_static_url(preview_url)
if has_path and file_name_no_ext == basename:
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
best_fallback = model
elif best_fallback is None:
best_fallback = model
if best_fallback:
preview_url = best_fallback.get("preview_url")
if preview_url:
from ..config import config
return config.get_preview_static_url(preview_url)
return "/loras_static/images/no-preview.png"
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
"""Get the Civitai URL for a model file"""
cache = await self.scanner.get_cached_data()
name_normalized = model_name.replace("\\", "/")
name_no_ext = name_normalized
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if name_no_ext.lower().endswith(ext):
name_no_ext = name_no_ext[: -len(ext)]
break
has_path = "/" in name_no_ext
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
best_fallback = None
for model in cache.raw_data:
if model["file_name"] == model_name:
file_name = model.get("file_name", "")
folder = model.get("folder", "")
file_name_no_ext = file_name
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if file_name_no_ext.lower().endswith(ext):
file_name_no_ext = file_name_no_ext[: -len(ext)]
break
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
civitai_data = model.get("civitai", {})
model_id = civitai_data.get("modelId")
version_id = civitai_data.get("id")
@@ -895,6 +957,27 @@ class BaseModelService(ABC):
"version_id": str(version_id) if version_id else None,
}
if has_path and file_name_no_ext == basename:
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
best_fallback = model
elif best_fallback is None:
best_fallback = model
if best_fallback:
civitai_data = best_fallback.get("civitai", {})
model_id = civitai_data.get("modelId")
if model_id:
version_id = civitai_data.get("id")
civitai_host = self.settings.get("civitai_host", "civitai.com")
civitai_url = build_civitai_model_page_url(
model_id, version_id, host=civitai_host
)
return {
"civitai_url": civitai_url,
"model_id": str(model_id),
"version_id": str(version_id) if version_id else None,
}
return {"civitai_url": None, "model_id": None, "version_id": None}
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
@@ -908,6 +991,17 @@ class BaseModelService(ABC):
)
if should_skip or metadata is None:
return None
# Prune stale example-image metadata entries whose files no longer
# exist on disk (e.g. a user deleted the files manually).
from ..utils.example_images_metadata import MetadataUpdater
was_modified = await MetadataUpdater.prune_stale_example_images(metadata)
if was_modified:
asyncio.create_task(
MetadataManager.save_metadata(file_path, metadata)
)
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
async def get_model_description(self, file_path: str) -> Optional[str]:

View File

@@ -224,7 +224,7 @@ class BatchImportService:
return False
for recipe in getattr(cache, "raw_data", []):
source_path = recipe.get("source_path") or recipe.get("source_url")
source_path = recipe.get("source_path")
if source_path and source_path == source:
return True
return False

View File

@@ -3,6 +3,7 @@ import logging
from typing import Dict
from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags
from ..utils.models import CheckpointMetadata
from ..config import config
@@ -45,7 +46,8 @@ class CheckpointService(BaseModelService):
"exclude": bool(checkpoint_data.get("exclude", False)),
"update_available": bool(checkpoint_data.get("update_available", False)),
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
}
def find_duplicate_hashes(self) -> Dict:

View File

@@ -193,6 +193,9 @@ class CivitaiBaseModelService:
"zimageturbo": "ZIT",
"zimagebase": "ZIB",
"anima": "ANI",
"ernie": "ERNI",
"ernie turbo": "ETRB",
"nucleus": "NUCL",
"svd": "SVD",
"ltxv": "LTXV",
"ltxv2": "LTV2",
@@ -418,6 +421,9 @@ class CivitaiBaseModelService:
"Kolors",
"NoobAI",
"Anima",
"Ernie",
"Ernie Turbo",
"Nucleus",
],
}

View File

@@ -2,6 +2,7 @@ import asyncio
import copy
import logging
import os
from collections import OrderedDict
from typing import Any, Optional, Dict, Tuple, List, Sequence
from .connectivity_guard import (
OFFLINE_FRIENDLY_MESSAGE,
@@ -45,6 +46,14 @@ class CivitaiClient:
self._initialized = True
self.base_url = "https://civitai.red/api/v1"
# In-memory cache to avoid redundant get_model_version_info calls
# within the same import/scan flow. Only successful results are cached.
# Uses OrderedDict with LRU eviction at MAX_CACHE_ENTRIES to prevent
# unbounded growth in long-running server processes.
self._version_info_cache: OrderedDict[
str, Tuple[Optional[Dict], Optional[str]]
] = OrderedDict()
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"
@@ -57,22 +66,57 @@ class CivitaiClient:
use_auth: bool = False,
**kwargs,
) -> Tuple[bool, Dict | str]:
"""Wrapper around downloader.make_request that surfaces rate limits."""
"""Wrapper around downloader.make_request that surfaces rate limits,
with retry for transient server errors (5xx, Cloudflare 524, network flakiness)."""
downloader = await get_downloader()
success, result = await downloader.make_request(
method,
url,
use_auth=use_auth,
**kwargs,
)
if not success and isinstance(result, RateLimitError):
if result.provider is None:
result.provider = "civitai_api"
raise result
if not success and is_offline_cooldown_error(result):
return False, OFFLINE_FRIENDLY_MESSAGE
return success, result
max_retries = 3
for attempt in range(max_retries):
downloader = await get_downloader()
success, result = await downloader.make_request(
method,
url,
use_auth=use_auth,
**kwargs,
)
if success:
return True, result
if isinstance(result, RateLimitError):
if result.provider is None:
result.provider = "civitai_api"
raise result
if is_offline_cooldown_error(result):
return False, OFFLINE_FRIENDLY_MESSAGE
# Transient server error — retry with exponential backoff
if self._is_transient_server_error(str(result)):
if attempt < max_retries - 1:
wait = 2**attempt # 1s, 2s, 4s
logger.info(
"Transient error on %s %s, retrying in %ds "
"(attempt %d/%d): %s",
method,
url,
wait,
attempt + 1,
max_retries,
result,
)
await asyncio.sleep(wait)
continue
logger.warning(
"All %d retries exhausted for %s %s: %s",
max_retries,
method,
url,
result,
)
return False, result
return False, result
return False, "Unexpected error in _make_request"
@staticmethod
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None:
@@ -201,6 +245,29 @@ class CivitaiClient:
return _from_value(payload)
@staticmethod
def _is_transient_server_error(message: str) -> bool:
"""Return True when the message indicates a transient upstream failure.
Recognises Cloudflare 524, generic 5xx, and connectivity-level flakiness
that should not be treated as a permanent failure.
"""
normalized = message.lower()
if "status 5" in normalized or "status 524" in normalized:
return True
if any(
keyword in normalized
for keyword in (
"connection refused",
"connection reset",
"temporary failure",
"name resolution",
"connection closed",
)
):
return True
return False
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
"""Get all versions of a model with local availability info"""
try:
@@ -223,6 +290,13 @@ class CivitaiClient:
logger.info("Civitai request skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
return None
if message:
if self._is_transient_server_error(message):
logger.info(
"Transient server error for model %s: %s",
model_id,
message,
)
return None
raise RuntimeError(message)
return None
except RateLimitError:
@@ -257,7 +331,7 @@ class CivitaiClient:
"GET",
f"{self.base_url}/models",
use_auth=True,
params={"ids": query},
params={"ids": query, "nsfw": "true"},
)
if not success:
return None
@@ -336,6 +410,25 @@ class CivitaiClient:
return None
target_version = self._select_target_version(model_data, model_id, version_id)
# If modelVersions is empty (e.g. CivitAI cache lag for newly published
# models) but a specific version_id is known, fall back to fetching the
# version directly via the individual model-versions endpoint, then
# enrich it with the model-level data we already have.
if target_version is None and version_id is not None:
logger.info(
"modelVersions empty for model %s; falling back to direct "
"version lookup for %s",
model_id,
version_id,
)
version = await self._fetch_version_by_id(version_id)
if version:
self._enrich_version_with_model_data(version, model_data)
self._remove_comfy_metadata(version)
return version
return None
if target_version is None:
return None
@@ -482,6 +575,14 @@ class CivitaiClient:
- The model version data or None if not found
- An error message if there was an error, or None on success
"""
# In-memory cache avoids redundant API calls within the same
# import/scan flow (e.g. _resolve_base_model_from_checkpoint
# followed by _resolve_and_populate_checkpoint with the same id).
if version_id in self._version_info_cache:
logger.debug("Cache hit for model version info: %s", version_id)
self._version_info_cache.move_to_end(version_id) # LRU bump
return self._version_info_cache[version_id]
try:
url = f"{self.base_url}/model-versions/{version_id}"
@@ -491,6 +592,11 @@ class CivitaiClient:
if success:
logger.debug("Successfully fetched model version info for: %s", version_id)
self._remove_comfy_metadata(result)
self._version_info_cache[version_id] = (result, None)
self._version_info_cache.move_to_end(version_id)
# Evict oldest entry when over capacity
if len(self._version_info_cache) > self._MAX_CACHE_ENTRIES:
self._version_info_cache.popitem(last=False)
return result, None
# Handle specific error cases
@@ -532,6 +638,13 @@ class CivitaiClient:
if not success:
if is_expected_offline_error(result):
return None
if self._is_transient_server_error(str(result)):
logger.info(
"Transient server error fetching image info for ID %s: %s",
image_id,
result,
)
return None
logger.error(
"Failed to fetch image info for ID %s from civitai.red: %s",
image_id,
@@ -577,6 +690,59 @@ class CivitaiClient:
logger.error(error_msg)
return None
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
"""Fetch full version details for up to 100 SHA256 hashes via the batch endpoint.
Uses POST /api/v1/model-versions/by-hash which returns full version
details including ``usageControl`` and ``earlyAccessEndsAt`` that are
not available from the model-level API.
Args:
hashes: List of SHA256 hashes (max 100 per batch; auto-split).
Returns:
List of version dicts or None on failure.
"""
if not hashes:
return []
BATCH_SIZE = 100
all_versions: List[Dict] = []
for start in range(0, len(hashes), BATCH_SIZE):
batch = hashes[start : start + BATCH_SIZE]
try:
success, result = await self._make_request(
"POST",
f"{self.base_url}/model-versions/by-hash",
use_auth=True,
json=batch,
)
if not success:
logger.warning(
"Batch by-hash request failed for %d hashes: %s",
len(batch),
result,
)
continue
if isinstance(result, list):
all_versions.extend(result)
else:
logger.debug(
"Unexpected by-hash response type: %s", type(result)
)
except RateLimitError:
raise
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error fetching model versions by hashes: %s", exc
)
return all_versions if all_versions else None
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
"""Fetch all models for a specific Civitai user."""
if not username:
@@ -587,7 +753,7 @@ class CivitaiClient:
"GET",
f"{self.base_url}/models",
use_auth=True,
params={"username": username},
params={"username": username, "nsfw": "true"},
)
if not success:

View File

@@ -18,6 +18,7 @@ from ..utils.constants import (
VALID_LORA_TYPES,
)
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
from ..utils.file_utils import calculate_sha256
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils
@@ -1364,7 +1365,7 @@ class DownloadManager:
f
for f in files
if f.get("primary")
and f.get("type") in ("Model", "Negative")
and f.get("type") in ("Model", "Negative", "Diffusion Model")
),
None,
)
@@ -1395,7 +1396,7 @@ class DownloadManager:
(
f
for f in files
if f.get("primary") and f.get("type") in ("Model", "Negative")
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model")
),
None,
)
@@ -2239,8 +2240,11 @@ class DownloadManager:
entry.file_name = os.path.splitext(os.path.basename(file_path))[0]
# Update size to actual downloaded file size
entry.size = os.path.getsize(file_path)
# Use SHA256 from API metadata (already set in from_civitai_info)
# Do not recalculate to avoid blocking during ComfyUI execution
# Compute SHA256 locally when the API response didn't include it
if not entry.sha256:
sha256 = await calculate_sha256(file_path)
if sha256:
entry.sha256 = sha256.lower()
entries.append(entry)
return entries

View File

@@ -64,6 +64,7 @@ class DownloadedVersionHistoryService:
self._db_path = db_path or _resolve_database_path()
self._settings = settings_manager or get_settings_manager()
self._lock = asyncio.Lock()
self._conn: sqlite3.Connection | None = None
self._schema_initialized = False
self._ensure_directory()
self._initialize_schema()
@@ -78,6 +79,12 @@ class DownloadedVersionHistoryService:
conn.row_factory = sqlite3.Row
return conn
def _get_conn(self) -> sqlite3.Connection:
if self._conn is None:
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
return self._conn
def _initialize_schema(self) -> None:
if self._schema_initialized:
return
@@ -116,33 +123,33 @@ class DownloadedVersionHistoryService:
timestamp = time.time()
async with self._lock:
with self._connect() as conn:
conn.execute(
"""
INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(model_type, version_id) DO UPDATE SET
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
last_seen_at = excluded.last_seen_at,
source = excluded.source,
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 0
""",
(
normalized_type,
normalized_version_id,
normalized_model_id,
timestamp,
timestamp,
source,
file_path,
active_library_name,
),
)
conn.commit()
conn = self._get_conn()
conn.execute(
"""
INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(model_type, version_id) DO UPDATE SET
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
last_seen_at = excluded.last_seen_at,
source = excluded.source,
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 0
""",
(
normalized_type,
normalized_version_id,
normalized_model_id,
timestamp,
timestamp,
source,
file_path,
active_library_name,
),
)
conn.commit()
async def mark_downloaded_bulk(
self,
@@ -180,26 +187,26 @@ class DownloadedVersionHistoryService:
return
async with self._lock:
with self._connect() as conn:
conn.executemany(
"""
INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(model_type, version_id) DO UPDATE SET
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
last_seen_at = excluded.last_seen_at,
source = excluded.source,
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 0
""",
payload,
)
conn.commit()
conn = self._get_conn()
conn.executemany(
"""
INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(model_type, version_id) DO UPDATE SET
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
last_seen_at = excluded.last_seen_at,
source = excluded.source,
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 0
""",
payload,
)
conn.commit()
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None:
async def mark_as_deleted(self, model_type: str, version_id: int) -> None:
normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id)
if normalized_type is None or normalized_version_id is None:
@@ -208,28 +215,28 @@ class DownloadedVersionHistoryService:
timestamp = time.time()
async with self._lock:
with self._connect() as conn:
conn.execute(
"""
INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
ON CONFLICT(model_type, version_id) DO UPDATE SET
last_seen_at = excluded.last_seen_at,
source = excluded.source,
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 1
""",
(
normalized_type,
normalized_version_id,
timestamp,
timestamp,
self._get_active_library_name(),
),
)
conn.commit()
conn = self._get_conn()
conn.execute(
"""
INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
ON CONFLICT(model_type, version_id) DO UPDATE SET
last_seen_at = excluded.last_seen_at,
source = excluded.source,
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 1
""",
(
normalized_type,
normalized_version_id,
timestamp,
timestamp,
self._get_active_library_name(),
),
)
conn.commit()
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
normalized_type = _normalize_model_type(model_type)
@@ -238,15 +245,15 @@ class DownloadedVersionHistoryService:
return False
async with self._lock:
with self._connect() as conn:
row = conn.execute(
"""
SELECT is_deleted_override
FROM downloaded_model_versions
WHERE model_type = ? AND version_id = ?
""",
(normalized_type, normalized_version_id),
).fetchone()
conn = self._get_conn()
row = conn.execute(
"""
SELECT is_deleted_override
FROM downloaded_model_versions
WHERE model_type = ? AND version_id = ?
""",
(normalized_type, normalized_version_id),
).fetchone()
return bool(row) and not bool(row["is_deleted_override"])
async def get_downloaded_version_ids(
@@ -258,16 +265,16 @@ class DownloadedVersionHistoryService:
return []
async with self._lock:
with self._connect() as conn:
rows = conn.execute(
"""
SELECT version_id
FROM downloaded_model_versions
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
ORDER BY version_id ASC
""",
(normalized_type, normalized_model_id),
).fetchall()
conn = self._get_conn()
rows = conn.execute(
"""
SELECT version_id
FROM downloaded_model_versions
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
ORDER BY version_id ASC
""",
(normalized_type, normalized_model_id),
).fetchall()
return [int(row["version_id"]) for row in rows]
async def get_downloaded_version_ids_bulk(
@@ -291,17 +298,17 @@ class DownloadedVersionHistoryService:
params: list[object] = [normalized_type, *normalized_model_ids]
async with self._lock:
with self._connect() as conn:
rows = conn.execute(
f"""
SELECT model_id, version_id
FROM downloaded_model_versions
WHERE model_type = ?
AND model_id IN ({placeholders})
AND is_deleted_override = 0
""",
params,
).fetchall()
conn = self._get_conn()
rows = conn.execute(
f"""
SELECT model_id, version_id
FROM downloaded_model_versions
WHERE model_type = ?
AND model_id IN ({placeholders})
AND is_deleted_override = 0
""",
params,
).fetchall()
result: dict[int, set[int]] = {}
for row in rows:

View File

@@ -3,6 +3,7 @@ import logging
from typing import Dict
from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags
from ..utils.models import EmbeddingMetadata
from ..config import config
@@ -45,7 +46,8 @@ class EmbeddingService(BaseModelService):
"exclude": bool(embedding_data.get("exclude", False)),
"update_available": bool(embedding_data.get("update_available", False)),
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
}
def find_duplicate_hashes(self) -> Dict:

View File

@@ -5,6 +5,7 @@ from typing import Dict, List, Optional
from .base_model_service import BaseModelService
from .model_query import resolve_sub_type
from .auto_tag_service import extract_auto_tags
from ..utils.models import LoraMetadata
from ..config import config
@@ -57,6 +58,7 @@ class LoraService(BaseModelService):
"civitai": self.filter_civitai_data(
lora_data.get("civitai", {}), minimal=True
),
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
}
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
@@ -310,8 +312,23 @@ class LoraService(BaseModelService):
"""Return cached raw metadata for a LoRA matching the given filename."""
cache = await self.scanner.get_cached_data(force_refresh=False)
fn_normalized = filename.replace("\\", "/")
fn_no_ext = fn_normalized
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if fn_no_ext.lower().endswith(ext):
fn_no_ext = fn_no_ext[: -len(ext)]
break
for lora in cache.raw_data if cache else []:
if lora.get("file_name") == filename:
file_name = lora.get("file_name", "")
folder = lora.get("folder", "")
file_name_no_ext = file_name
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if file_name_no_ext.lower().endswith(ext):
file_name_no_ext = file_name_no_ext[: -len(ext)]
break
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
if fn_no_ext in (file_name_no_ext, path_name):
return lora
return None
@@ -399,7 +416,10 @@ class LoraService(BaseModelService):
locked_loras = locked_loras[:target_count]
# Filter out locked LoRAs from available pool
locked_names = {lora["name"] for lora in locked_loras}
locked_names = {
os.path.basename(lora["name"]) if "/" in str(lora.get("name", "")) else lora["name"]
for lora in locked_loras
}
available_pool = [
l for l in available_loras if l["file_name"] not in locked_names
]
@@ -454,7 +474,7 @@ class LoraService(BaseModelService):
result_loras.append(
{
"name": lora["file_name"],
"name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
"strength": model_str,
"clipStrength": clip_str,
"active": True,
@@ -670,8 +690,9 @@ class LoraService(BaseModelService):
# Return minimal data needed for cycling
return [
{
"file_name": lora["file_name"],
"file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
"model_name": lora.get("model_name", lora["file_name"]),
"folder": lora.get("folder", ""),
}
for lora in available_loras
]

View File

@@ -7,6 +7,7 @@ class ModelHashIndex:
def __init__(self):
self._hash_to_path: Dict[str, str] = {}
self._filename_to_hash: Dict[str, str] = {}
self._autov2_to_path: Dict[str, str] = {}
# New data structures for tracking duplicates
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
@@ -63,6 +64,9 @@ class ModelHashIndex:
# Add new mappings
self._hash_to_path[sha256] = file_path
self._filename_to_hash[filename] = sha256
# AutoV2 = first 10 chars of SHA256
if len(sha256) >= 10:
self._autov2_to_path[sha256[:10]] = file_path
def _get_filename_from_path(self, file_path: str) -> str:
"""Extract filename without extension from path"""
@@ -79,6 +83,12 @@ class ModelHashIndex:
hash_val = h
break
if hash_val is None:
for h, paths in self._duplicate_hashes.items():
if file_path in paths:
hash_val = h
break
# If we didn't find a hash, nothing to do
if not hash_val:
return
@@ -151,7 +161,12 @@ class ModelHashIndex:
del self._duplicate_filenames[filename]
if filename in self._filename_to_hash:
del self._filename_to_hash[filename]
# Remove from AutoV2 index
autov2_keys_to_remove = [k for k, v in self._autov2_to_path.items() if v == file_path]
for k in autov2_keys_to_remove:
del self._autov2_to_path[k]
def remove_by_hash(self, sha256: str) -> None:
"""Remove entry by hash"""
sha256 = sha256.lower()
@@ -171,6 +186,10 @@ class ModelHashIndex:
# Remove hash-to-path mapping
del self._hash_to_path[sha256]
autov2_key = sha256[:10]
if autov2_key in self._autov2_to_path:
del self._autov2_to_path[autov2_key]
# Update filename-to-hash and duplicate filenames for all paths
for path_to_remove in paths_to_remove:
fname = self._get_filename_from_path(path_to_remove)
@@ -189,13 +208,24 @@ class ModelHashIndex:
# If only one entry remains, it's no longer a duplicate
del self._duplicate_filenames[fname]
def has_hash(self, sha256: str) -> bool:
"""Check if hash exists in index"""
return sha256.lower() in self._hash_to_path
def get_path(self, sha256: str) -> Optional[str]:
"""Get file path for a hash"""
return self._hash_to_path.get(sha256.lower())
def has_hash(self, hash_value: str) -> bool:
"""Check if hash exists in index (SHA256 or AutoV2)"""
normalized = hash_value.lower()
if normalized in self._hash_to_path:
return True
if len(normalized) == 10:
return normalized in self._autov2_to_path
return False
def get_path(self, hash_value: str) -> Optional[str]:
"""Get file path for a hash (SHA256 or AutoV2)"""
normalized = hash_value.lower()
path = self._hash_to_path.get(normalized)
if path is not None:
return path
if len(normalized) == 10:
return self._autov2_to_path.get(normalized)
return None
def get_hash(self, file_path: str) -> Optional[str]:
"""Get hash for a file path"""
@@ -203,13 +233,16 @@ class ModelHashIndex:
return self._filename_to_hash.get(filename)
def get_hash_by_filename(self, filename: str) -> Optional[str]:
"""Get hash for a filename without extension"""
"""Get hash for a filename (bare basename or path-prefixed name)"""
if "/" in filename or "\\" in filename:
filename = os.path.splitext(os.path.basename(filename.replace("\\", "/")))[0]
return self._filename_to_hash.get(filename)
def clear(self) -> None:
"""Clear all entries"""
self._hash_to_path.clear()
self._filename_to_hash.clear()
self._autov2_to_path.clear()
self._duplicate_hashes.clear()
self._duplicate_filenames.clear()

View File

@@ -111,6 +111,11 @@ class ModelLifecycleService:
self._scanner._hash_index.remove_by_path(file_path)
await self._sync_update_for_model(model_id)
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
if callable(persist_current_cache):
await persist_current_cache()
return {"success": True, "deleted_files": deleted_files}
@staticmethod

View File

@@ -5,7 +5,7 @@ import logging
import random
from typing import Optional, Dict, Tuple, Any, List, Sequence
from .downloader import get_downloader
from .errors import RateLimitError
from .errors import RateLimitError, ResourceNotFoundError
try:
from bs4 import BeautifulSoup
@@ -108,6 +108,18 @@ class ModelMetadataProvider(ABC):
) -> Optional[Dict[int, Dict]]:
"""Fetch model versions for multiple model ids when supported."""
raise NotImplementedError
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
"""Fetch full version details for multiple SHA256 hashes.
Used specifically to retrieve ``usageControl`` which is only
available from the per-version / by-hash API, not from model-level
responses. Providers that cannot resolve hashes should let the
default ``NotImplementedError`` propagate.
"""
raise NotImplementedError
@abstractmethod
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
@@ -140,6 +152,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
self, model_ids: Sequence[int]
) -> Optional[Dict[int, Dict]]:
return await self.client.get_model_versions_bulk(model_ids)
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
return await self.client.get_model_versions_by_hashes(hashes)
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
return await self.client.get_model_version(model_id, version_id)
@@ -465,6 +482,7 @@ class FallbackMetadataProvider(ModelMetadataProvider):
return None, "Model not found"
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
not_found_confirmed = False
for provider, label in self._iter_providers():
try:
result = await self._call_with_rate_limit(
@@ -475,8 +493,24 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result:
return result
except RateLimitError as exc:
if not_found_confirmed:
logger.debug(
"Suppressing rate limit from %s for model %s: "
"already confirmed as not found by another provider",
label,
model_id,
)
return None
exc.provider = exc.provider or label
raise exc
except ResourceNotFoundError:
not_found_confirmed = True
logger.debug(
"Provider %s reports model %s as not found",
label,
model_id,
)
continue
except Exception as e:
logger.debug("Provider %s failed for get_model_versions: %s", label, e)
continue
@@ -519,6 +553,32 @@ class FallbackMetadataProvider(ModelMetadataProvider):
continue
return None, "No provider could retrieve the data"
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
for provider, label in self._iter_providers():
try:
result = await self._call_with_rate_limit(
label,
provider.get_model_versions_by_hashes,
hashes,
)
if result is not None:
return result
except NotImplementedError:
continue
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
except Exception as e:
logger.debug(
"Provider %s failed for get_model_versions_by_hashes: %s",
label,
e,
)
continue
return None
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
for provider, label in self._iter_providers():
try:
@@ -593,6 +653,15 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
model_ids,
)
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
return await self._rate_limit_helper.run(
self._label,
self._provider.get_model_versions_by_hashes,
hashes,
)
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
return await self._rate_limit_helper.run(
self._label,
@@ -669,6 +738,17 @@ class ModelMetadataProviderManager:
provider = self._get_provider(provider_name)
return await provider.get_model_version_info(version_id)
async def get_model_versions_by_hashes(
self,
hashes: List[str],
provider_name: str = None,
) -> Optional[List[Dict]]:
provider = self._get_provider(provider_name)
try:
return await provider.get_model_versions_by_hashes(hashes)
except NotImplementedError:
return None
async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
"""Fetch models owned by the specified user"""
provider = self._get_provider(provider_name)

View File

@@ -96,6 +96,7 @@ class FilterCriteria:
folder_exclude: Optional[Sequence[str]] = None
base_models: Optional[Sequence[str]] = None
tags: Optional[Dict[str, str]] = None
auto_tags: Optional[Dict[str, str]] = None
favorites_only: bool = False
search_options: Optional[Dict[str, Any]] = None
model_types: Optional[Sequence[str]] = None
@@ -359,10 +360,37 @@ class ModelFilterSet:
]
model_types_duration = time.perf_counter() - t0
auto_tags_duration = 0
auto_tag_filters = criteria.auto_tags or {}
if auto_tag_filters:
t0 = time.perf_counter()
include_at = set()
exclude_at = set()
for tag, state in auto_tag_filters.items():
if not tag:
continue
if state == "exclude":
exclude_at.add(tag)
else:
include_at.add(tag)
if include_at:
items = [
item for item in items
if any(tag in include_at for tag in (item.get("auto_tags") or []))
]
if exclude_at:
items = [
item for item in items
if not any(tag in exclude_at for tag in (item.get("auto_tags") or []))
]
auto_tags_duration = time.perf_counter() - t0
duration = time.perf_counter() - overall_start
if duration > 0.1: # Only log if it's potentially slow
logger.debug(
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs). "
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs, auto_tags: %.3fs). "
"Count: %d -> %d",
duration,
sfw_duration,
@@ -371,6 +399,7 @@ class ModelFilterSet:
base_models_duration,
tags_duration,
model_types_duration,
auto_tags_duration,
initial_count,
len(items),
)

View File

@@ -9,7 +9,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Set,
from ..utils.models import BaseModelMetadata
from ..config import config
from ..utils.file_utils import find_preview_file, get_preview_extension
from ..utils.file_utils import find_preview_file, get_preview_extension, calculate_sha256
from ..utils.metadata_manager import MetadataManager
from ..utils.civitai_utils import resolve_license_info
from .model_cache import ModelCache
@@ -1067,19 +1067,24 @@ class ModelScanner:
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes)
if not model_data.get('sha256') and file_path:
try:
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
sha256 = await calculate_sha256(file_path)
if sha256:
model_data['sha256'] = sha256.lower()
if isinstance(metadata, BaseModelMetadata):
metadata.sha256 = sha256.lower()
await MetadataManager.save_metadata(file_path, metadata)
except Exception as e:
logger.error(f"Failed to compute SHA256 for {file_path}: {e}")
# Skip excluded models
if model_data.get('exclude', False):
excluded_models.append(model_data['file_path'])
return None
# Check for duplicate filename before adding to hash index
# filename = os.path.splitext(os.path.basename(file_path))[0]
# existing_hash = hash_index.get_hash_by_filename(filename)
# if existing_hash and existing_hash != model_data.get('sha256', '').lower():
# existing_path = hash_index.get_path(existing_hash)
# if existing_path and existing_path != file_path:
# logger.warning(f"Duplicate filename detected: '{filename}' - files: '{existing_path}' and '{file_path}'")
return model_data
async def _apply_scan_result(self, scan_result: CacheBuildResult) -> None:
@@ -1105,6 +1110,39 @@ class ModelScanner:
await self._cache.resort()
self._log_duplicate_filename_summary()
def _log_duplicate_filename_summary(self) -> None:
"""Log a batched summary of duplicate filename conflicts once per scan."""
# Duplicate filename detection is only relevant for LoRAs, which use
# basename-only syntax (<lora:name:strength>). Checkpoints and embeddings
# use full relative paths for resolution, so conflicts are not ambiguous.
if self._hash_index is None or self.model_type != "lora":
return
# When full path syntax is active, duplicate filenames across subfolders
# are fully qualified, so there is no ambiguity — skip the warning.
if get_settings_manager().get("lora_syntax_format", "legacy") == "full":
return
duplicates = self._hash_index.get_duplicate_filenames()
if not duplicates:
return
total_files = sum(len(paths) for paths in duplicates.values())
conflict_count = len(duplicates)
model_type_label = self.model_type or "model"
logger.warning(
"Duplicate filename conflict detected: %d %s filename(s) "
"are shared by %d files total, causing ambiguity in %s resolution. "
"Open the Doctor panel to resolve one-click.",
conflict_count,
model_type_label,
total_files,
model_type_label.capitalize(),
)
async def _sync_download_history(
self,
raw_data: List[Mapping[str, Any]],
@@ -1456,6 +1494,15 @@ class ModelScanner:
file_path_override=normalized_new_path,
)
# Ensure sha256 is populated even when metadata doesn't have it
if not cache_entry.get('sha256') and normalized_new_path and os.path.exists(normalized_new_path):
try:
sha256 = await calculate_sha256(normalized_new_path)
if sha256:
cache_entry['sha256'] = sha256.lower()
except Exception as e:
logger.error(f"Failed to compute SHA256 for {normalized_new_path}: {e}")
if recalculate_type:
cache_entry = self.adjust_cached_entry(cache_entry)
@@ -1555,12 +1602,39 @@ class ModelScanner:
"""Get model information by name"""
try:
cache = await self.get_cached_data()
name_normalized = name.replace("\\", "/")
name_no_ext = name_normalized
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if name_no_ext.lower().endswith(ext):
name_no_ext = name_no_ext[: -len(ext)]
break
has_path = "/" in name_no_ext
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
best_fallback = None
for model in cache.raw_data:
if model.get("file_name") == name:
file_name = model.get("file_name", "")
folder = model.get("folder", "")
file_name_no_ext = file_name
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if file_name_no_ext.lower().endswith(ext):
file_name_no_ext = file_name_no_ext[: -len(ext)]
break
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
return model
return None
if has_path and file_name_no_ext == basename:
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
best_fallback = model
elif best_fallback is None:
best_fallback = model
return best_fallback
except Exception as e:
logger.error(f"Error getting model info by name: {e}", exc_info=True)
return None

View File

@@ -69,6 +69,7 @@ class ModelVersionRecord:
early_access_ends_at: Optional[str] = None
sort_index: int = 0
is_early_access: bool = False
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
@dataclass
@@ -101,11 +102,14 @@ class ModelUpdateRecord:
return [version.version_id for version in self.versions if version.is_in_library]
def has_update(self, hide_early_access: bool = False) -> bool:
def has_update(
self, hide_early_access: bool = False, hide_non_downloadable: bool = True
) -> bool:
"""Return True when a non-ignored remote version newer than the newest local copy is available.
Args:
hide_early_access: If True, exclude early access versions from update check.
hide_non_downloadable: If True, exclude versions that don't allow downloads.
"""
if self.should_ignore_model:
@@ -121,6 +125,7 @@ class ModelUpdateRecord:
not version.is_in_library
and not version.should_ignore
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
and not (hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version))
for version in self.versions
)
@@ -129,6 +134,8 @@ class ModelUpdateRecord:
continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue
if version.version_id > max_in_library:
return True
return False
@@ -155,11 +162,18 @@ class ModelUpdateRecord:
# Phase 1: Basic EA flag from bulk API
return version.is_early_access
@staticmethod
def _is_downloadable(version: ModelVersionRecord) -> bool:
if version.usage_control is None:
return True
return version.usage_control == "Download"
def has_update_for_base(
self,
local_version_id: Optional[int],
local_base_model: Optional[str],
hide_early_access: bool = False,
hide_non_downloadable: bool = True,
) -> bool:
"""Return True when a newer remote version with the same base model exists.
@@ -167,6 +181,7 @@ class ModelUpdateRecord:
local_version_id: The current local version id.
local_base_model: The base model to filter by.
hide_early_access: If True, exclude early access versions from update check.
hide_non_downloadable: If True, exclude versions that don't allow downloads.
"""
if self.should_ignore_model:
@@ -197,6 +212,8 @@ class ModelUpdateRecord:
continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue
version_base = _normalize_base_model(version.base_model)
if version_base != normalized_base:
continue
@@ -230,6 +247,7 @@ class ModelUpdateService:
preview_url TEXT,
is_in_library INTEGER NOT NULL DEFAULT 0,
should_ignore INTEGER NOT NULL DEFAULT 0,
usage_control TEXT,
PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
);
@@ -465,6 +483,10 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions "
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
),
"usage_control": (
"ALTER TABLE model_update_versions "
"ADD COLUMN usage_control TEXT"
),
}
for column, statement in migrations.items():
@@ -967,18 +989,22 @@ class ModelUpdateService:
fallback_attempted = True
try:
response = await metadata_provider.get_model_versions(model_id)
if response is not None:
await self._enrich_version_entries(
metadata_provider,
{model_id: response},
)
except RateLimitError:
raise
except ResourceNotFoundError as exc:
fallback_error_message = str(exc) or "resource not found"
mark_model_as_ignored = True
except Exception as exc: # pragma: no cover - defensive log
logger.error(
logger.warning(
"Failed to fetch versions for model %s (%s): %s",
model_id,
model_type,
exc,
exc_info=True,
)
fallback_error_message = str(exc)
if response is not None:
@@ -1061,6 +1087,136 @@ class ModelUpdateService:
self._upsert_record(record)
return record
async def _enrich_version_entries(
self,
metadata_provider,
responses_by_model_id: Dict[int, Mapping],
) -> None:
"""Enrich version entries with ``usageControl`` via batch hash endpoint.
The model-level API does not include ``usageControl`` on version
entries. This method collects SHA256 hashes from every version's
primary model file, calls ``POST /api/v1/model-versions/by-hash``
(up to 100 hashes per request), and injects ``usageControl`` +
``earlyAccessEndsAt`` into each version entry dict in-place.
"""
if not metadata_provider or not responses_by_model_id:
return
hashes_by_version: Dict[int, str] = {}
for response in responses_by_model_id.values():
hashes_by_version.update(
self._collect_hashes_from_response(response)
)
if not hashes_by_version:
return
version_ids_by_hash: Dict[str, List[int]] = {}
for version_id, sha256 in hashes_by_version.items():
version_ids_by_hash.setdefault(sha256, []).append(version_id)
all_hashes = list(version_ids_by_hash.keys())
BATCH_SIZE = 100
enrichment: Dict[int, Dict] = {}
try:
for start in range(0, len(all_hashes), BATCH_SIZE):
batch = all_hashes[start : start + BATCH_SIZE]
try:
enriched = await metadata_provider.get_model_versions_by_hashes(
batch
)
except NotImplementedError:
return
except RateLimitError:
raise
except Exception:
continue
if not enriched:
continue
for entry in enriched:
if not isinstance(entry, dict):
continue
version_id = entry.get("id")
if version_id is None:
continue
enrichment[version_id] = {
"usageControl": _normalize_string(
entry.get("usageControl")
),
"earlyAccessEndsAt": _normalize_string(
entry.get("earlyAccessEndsAt")
),
}
except RateLimitError:
raise
if not enrichment:
return
for response in responses_by_model_id.values():
versions = response.get("modelVersions")
if not isinstance(versions, list):
continue
for version in versions:
if not isinstance(version, dict):
continue
version_id = version.get("id")
if version_id not in enrichment:
continue
extra = enrichment[version_id]
if extra.get("usageControl") and not version.get("usageControl"):
version["usageControl"] = extra["usageControl"]
if extra.get("earlyAccessEndsAt") and not version.get(
"earlyAccessEndsAt"
):
version["earlyAccessEndsAt"] = extra["earlyAccessEndsAt"]
@staticmethod
def _collect_hashes_from_response(response: Mapping) -> Dict[int, str]:
"""Extract ``{version_id: sha256}`` from a model-level API response.
Returns an empty dict if the response structure is unexpected.
"""
result: Dict[int, str] = {}
versions = response.get("modelVersions")
if not isinstance(versions, list):
return result
for entry in versions:
if not isinstance(entry, dict):
continue
version_id = _normalize_int(entry.get("id"))
if version_id is None:
continue
sha256 = ModelUpdateService._extract_sha256_from_version_entry(entry)
if sha256:
result[version_id] = sha256
return result
@staticmethod
def _extract_sha256_from_version_entry(entry: Mapping) -> Optional[str]:
"""Return the SHA256 hash from the primary model file of a version entry."""
files = entry.get("files")
if not isinstance(files, list):
return None
for file_info in files:
if not isinstance(file_info, dict):
continue
if file_info.get("type") != "Model":
continue
primary = file_info.get("primary")
if primary is not True and str(primary).strip().lower() != "true":
continue
hashes = file_info.get("hashes")
if isinstance(hashes, dict):
sha256 = hashes.get("SHA256")
if sha256:
return sha256
return None
async def _fetch_model_versions_bulk(
self,
metadata_provider,
@@ -1112,6 +1268,7 @@ class ModelUpdateService:
len(aggregated),
provider_name,
)
await self._enrich_version_entries(metadata_provider, aggregated)
return aggregated
async def _collect_local_versions(
@@ -1239,6 +1396,7 @@ class ModelUpdateService:
sort_index=sort_map.get(version_id, index),
early_access_ends_at=remote_version.early_access_ends_at,
is_early_access=remote_version.is_early_access,
usage_control=remote_version.usage_control,
)
)
@@ -1337,6 +1495,7 @@ class ModelUpdateService:
# Check availability field from bulk API for basic EA detection
availability = _normalize_string(entry.get("availability"))
is_early_access = availability == "EarlyAccess"
usage_control = _normalize_string(entry.get("usageControl"))
return ModelVersionRecord(
version_id=version_id,
@@ -1350,6 +1509,7 @@ class ModelUpdateService:
early_access_ends_at=early_access_ends_at,
sort_index=index,
is_early_access=is_early_access,
usage_control=usage_control,
)
def _extract_size_bytes(self, files) -> Optional[int]:
@@ -1464,7 +1624,7 @@ class ModelUpdateService:
f"""
SELECT model_id, version_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access
is_early_access, usage_control
FROM model_update_versions
WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC
@@ -1492,6 +1652,7 @@ class ModelUpdateService:
early_access_ends_at=row["early_access_ends_at"],
sort_index=_normalize_int(row["sort_index"]) or 0,
is_early_access=bool(row["is_early_access"]),
usage_control=row["usage_control"],
)
)
@@ -1548,8 +1709,8 @@ class ModelUpdateService:
INSERT INTO model_update_versions (
version_id, model_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
is_early_access, usage_control
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
version.version_id,
@@ -1564,6 +1725,7 @@ class ModelUpdateService:
1 if version.should_ignore else 0,
version.early_access_ends_at,
1 if version.is_early_access else 0,
version.usage_control,
),
)
conn.commit()

View File

@@ -38,6 +38,7 @@ class PersistentRecipeCache:
"json_path",
"title",
"folder",
"source_path",
"base_model",
"fingerprint",
"created_date",
@@ -334,6 +335,7 @@ class PersistentRecipeCache:
json_path TEXT,
title TEXT,
folder TEXT,
source_path TEXT,
base_model TEXT,
fingerprint TEXT,
created_date REAL,
@@ -358,6 +360,13 @@ class PersistentRecipeCache:
);
"""
)
# Migration: add source_path column to existing databases
try:
conn.execute(
"ALTER TABLE recipes ADD COLUMN source_path TEXT"
)
except Exception:
pass # column already exists
conn.commit()
self._schema_initialized = True
except Exception as exc:
@@ -406,6 +415,7 @@ class PersistentRecipeCache:
json_path,
recipe.get("title"),
recipe.get("folder"),
recipe.get("source_path"),
recipe.get("base_model"),
recipe.get("fingerprint"),
float(recipe.get("created_date") or 0.0),
@@ -456,6 +466,7 @@ class PersistentRecipeCache:
"file_path": row["file_path"] or "",
"title": row["title"] or "",
"folder": row["folder"] or "",
"source_path": row["source_path"] or "",
"base_model": row["base_model"] or "",
"fingerprint": row["fingerprint"] or "",
"created_date": row["created_date"] or 0.0,

View File

@@ -65,7 +65,7 @@ class RecipeScanner:
cls._instance._civitai_client = None # Will be lazily initialized
return cls._instance
REPAIR_VERSION = 3
REPAIR_VERSION = 4
def __init__(
self,
@@ -292,6 +292,32 @@ class RecipeScanner:
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
return False
# 1.5 Detect and clear corrupted checkpoint (LoRA data saved as checkpoint).
# A checkpoint whose modelVersionId also appears in a LoRA entry is
# definitely wrong — the CivitAI import code used to pick
# modelVersionIds[0] as the checkpoint, which was often a LoRA.
# Clearing it lets the enrichment flow re-resolve the correct
# checkpoint from CivitAI image metadata.
cp = recipe.get("checkpoint")
lora_mvids = {
l.get("modelVersionId")
for l in recipe.get("loras", [])
if l.get("modelVersionId")
}
if cp and cp.get("modelVersionId") and cp["modelVersionId"] in lora_mvids:
cp_mvid = cp["modelVersionId"]
logger.info(
"Recipe %s: checkpoint modelVersionId %s matches a LoRA — "
"clearing corrupted checkpoint and removing matching LoRA entry",
recipe.get("id"),
cp_mvid,
)
recipe["checkpoint"] = None
recipe["loras"] = [
l for l in recipe.get("loras", [])
if l.get("modelVersionId") != cp_mvid
]
# 2. Identification: Is repair needed?
has_checkpoint = (
"checkpoint" in recipe
@@ -504,6 +530,9 @@ class RecipeScanner:
self._cache.raw_data = recipes
self._update_folder_metadata(self._cache)
self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration)
if self._backfill_source_path_if_needed(recipes, json_paths):
self._persistent_cache.save_cache(recipes, json_paths)
return self._cache
else:
# Partial update: some files changed
@@ -514,6 +543,8 @@ class RecipeScanner:
self._cache.raw_data = recipes
self._update_folder_metadata(self._cache)
self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration)
self._backfill_source_path_if_needed(recipes, json_paths)
# Persist updated cache
self._persistent_cache.save_cache(recipes, json_paths)
return self._cache
@@ -642,6 +673,34 @@ class RecipeScanner:
return recipes, changed, json_paths
def _backfill_source_path_if_needed(
self,
recipes: List[Dict],
json_paths: Dict[str, str],
) -> bool:
"""Backfill source_path from recipe JSON files if missing from cache.
Returns True if any recipes were updated (caller should persist cache).
"""
updated = False
for recipe in recipes:
if recipe.get("source_path"):
continue
recipe_id = str(recipe.get("id", ""))
json_path = json_paths.get(recipe_id)
if not json_path or not os.path.exists(json_path):
continue
try:
with open(json_path, "r", encoding="utf-8") as f:
json_data = json.load(f)
file_source_path = json_data.get("source_path")
if file_source_path:
recipe["source_path"] = file_source_path
updated = True
except Exception:
pass
return updated
def _full_directory_scan_sync(
self, recipes_dir: str
) -> Tuple[List[Dict], Dict[str, str]]:
@@ -2484,6 +2543,7 @@ class RecipeScanner:
continue
file_name = None
folder = ""
hash_value = (lora.get("hash") or "").lower()
if (
hash_value
@@ -2493,6 +2553,11 @@ class RecipeScanner:
file_path = self._lora_scanner._hash_index.get_path(hash_value)
if file_path:
file_name = os.path.splitext(os.path.basename(file_path))[0]
if lora_cache is not None:
for cached_lora in getattr(lora_cache, "raw_data", []):
if cached_lora.get("file_path") == file_path:
folder = cached_lora.get("folder", "")
break
if not file_name and lora.get("modelVersionId") and lora_cache is not None:
for cached_lora in getattr(lora_cache, "raw_data", []):
@@ -2507,13 +2572,16 @@ class RecipeScanner:
file_name = os.path.splitext(os.path.basename(cached_path))[
0
]
folder = cached_lora.get("folder", "")
break
if not file_name:
file_name = lora.get("file_name", "unknown-lora")
folder = lora.get("folder", "")
lora_name = f"{folder}/{file_name}" if folder else file_name
strength = lora.get("strength", 1.0)
syntax_parts.append(f"<lora:{file_name}:{strength}>")
syntax_parts.append(f"<lora:{lora_name}:{strength}>")
return syntax_parts

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import base64
import io
import os
@@ -14,6 +15,7 @@ from PIL import Image
from ...utils.utils import calculate_recipe_fingerprint
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
from ...recipes.enrichment import RecipeEnricher
from .errors import (
RecipeDownloadError,
RecipeNotFoundError,
@@ -170,9 +172,11 @@ class RecipeAnalysisService:
await self._download_image(url, temp_path)
if metadata is None and not is_video:
metadata = self._exif_utils.extract_image_metadata(temp_path)
metadata = await asyncio.to_thread(
self._exif_utils.extract_image_metadata, temp_path
)
return await self._parse_metadata(
result = await self._parse_metadata(
metadata or {},
recipe_scanner=recipe_scanner,
image_path=temp_path,
@@ -180,6 +184,37 @@ class RecipeAnalysisService:
is_video=is_video,
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]
recipe_for_enrich = {
"gen_params": result.payload.get("gen_params", {}),
"loras": result.payload.get("loras", []),
"base_model": result.payload.get("base_model", "") or "",
"checkpoint": result.payload.get("checkpoint") or result.payload.get("model"),
"source_path": url,
}
await RecipeEnricher.enrich_recipe(
recipe=recipe_for_enrich,
civitai_client=civitai_client,
request_params=None,
prefetched_civitai_meta_raw=image_info.get("meta"),
prefetched_model_version_id=mvid,
)
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
if recipe_for_enrich.get("checkpoint"):
result.payload["checkpoint"] = recipe_for_enrich["checkpoint"]
if recipe_for_enrich.get("base_model"):
result.payload["base_model"] = recipe_for_enrich["base_model"]
return result
finally:
if temp_path:
self._safe_cleanup(temp_path)
@@ -199,7 +234,9 @@ class RecipeAnalysisService:
if not os.path.isfile(normalized_path):
raise RecipeNotFoundError("File not found")
metadata = self._exif_utils.extract_image_metadata(normalized_path)
metadata = await asyncio.to_thread(
self._exif_utils.extract_image_metadata, normalized_path
)
if not metadata:
return self._metadata_not_found_response(normalized_path)

View File

@@ -96,7 +96,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"compact_mode": False,
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
"model_name_display": "model_name",
"lora_syntax_format": "legacy",
"model_card_footer_action": "replace_preview",
"show_version_on_card": True,
"update_flag_strategy": "same_base",
"auto_organize_exclusions": [],
"metadata_refresh_skip_paths": [],

View File

@@ -4,7 +4,9 @@ from __future__ import annotations
import os
from typing import Awaitable, Callable, Dict, List, Sequence
from typing import Awaitable, Callable, Dict, List, Sequence, Tuple
from .auto_tag_service import extract_auto_tags
class TagUpdateService:
@@ -20,9 +22,8 @@ class TagUpdateService:
new_tags: Sequence[str],
metadata_loader: Callable[[str], Awaitable[Dict[str, object]]],
update_cache: Callable[[str, str, Dict[str, object]], Awaitable[bool]],
) -> List[str]:
"""Add tags to a metadata entry while keeping case-insensitive uniqueness."""
) -> Tuple[List[str], List[str]]:
"""Add tags to a metadata entry and return updated tags and auto_tags."""
base, _ = os.path.splitext(file_path)
metadata_path = f"{base}.metadata.json"
metadata = await metadata_loader(metadata_path)
@@ -44,5 +45,6 @@ class TagUpdateService:
await self._metadata_manager.save_metadata(file_path, metadata)
await update_cache(file_path, file_path, metadata)
return existing_tags
auto_tags = extract_auto_tags(metadata)
return existing_tags, auto_tags

View File

@@ -7,7 +7,7 @@ from typing import Any, Dict, Iterable, Mapping, Sequence
from urllib.parse import parse_qs, urlparse, urlunparse
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red"})
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red", "civitai.green"})
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
_LICENSE_DEFAULTS: Dict[str, Any] = {
@@ -239,9 +239,9 @@ def _resolve_commercial_bits(values: Sequence[str]) -> int:
normalized_values.add(normalized)
has_sell = "sell" in normalized_values
has_rent = has_sell or "rent" in normalized_values
has_rentcivit = has_rent or "rentcivit" in normalized_values
has_image = has_sell or "image" in normalized_values
has_rent = "rent" in normalized_values
has_rentcivit = "rentcivit" in normalized_values
has_image = "image" in normalized_values
commercial_bits = (
(1 if has_sell else 0) << 3

View File

@@ -178,5 +178,8 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
"Wan Video 2.5 I2V",
"Hunyuan Video",
"Anima",
"Ernie",
"Ernie Turbo",
"Nucleus",
]
)

View File

@@ -397,13 +397,12 @@ class DownloadManager:
models_with_hash = len(all_models_with_hash)
# Calculate pending count: check which models actually need processing
# A model is pending if it has a hash, is not in processed_models,
# and its folder doesn't exist or is empty
# Calculate pending count: check which models actually need processing.
# A model is pending if it has a hash, is not already processed or known-failed,
# and its folder doesn't exist or is empty.
pending_hashes = set()
for model_hash, model_name in all_models_with_hash:
if model_hash not in processed_models:
# Check if model folder exists with files
if model_hash not in processed_models and model_hash not in failed_models:
model_dir = ExampleImagePathResolver.get_model_folder(
model_hash, active_library
)

View File

@@ -452,3 +452,111 @@ class MetadataUpdater:
except Exception as e:
logger.error(f"Error parsing image metadata: {e}", exc_info=True)
return None
@staticmethod
async def prune_stale_example_images(metadata) -> bool:
"""Remove example-image metadata entries whose files no longer exist on disk.
Checks ``civitai.customImages`` (by ``id``) and ``civitai.images`` entries
that have an empty ``url`` (no remote fallback) against actual files in
the model's example-image folder. Stale entries are removed in-place so
the caller can persist the cleaned metadata afterwards.
Args:
metadata: A ``BaseModelMetadata`` instance (modified in place).
Returns:
True if at least one entry was removed.
"""
from ..utils.example_images_paths import get_model_folder
model_hash = getattr(metadata, "sha256", None)
if not model_hash:
return False
model_folder = get_model_folder(model_hash)
if not model_folder:
return False
civitai = getattr(metadata, "civitai", None)
if not isinstance(civitai, dict):
return False
has_changes = False
custom_images = civitai.get("customImages")
if isinstance(custom_images, list) and custom_images:
stale: list[int] = []
for idx, img in enumerate(custom_images):
img_id = img.get("id", "")
if not img_id:
continue
if not os.path.isdir(model_folder):
stale.append(idx)
else:
found = False
try:
prefix = f"custom_{img_id}"
for fname in os.listdir(model_folder):
if fname.startswith(prefix) and os.path.isfile(
os.path.join(model_folder, fname)
):
found = True
break
except OSError:
stale.append(idx)
continue
if not found:
stale.append(idx)
if stale:
for idx in reversed(stale):
custom_images.pop(idx)
has_changes = True
logger.info(
"Pruned %d stale custom image(s) for %s",
len(stale),
getattr(metadata, "model_name", model_hash),
)
images = civitai.get("images")
if isinstance(images, list) and images:
stale: list[int] = []
for idx, img in enumerate(images):
if img.get("url", ""):
# Has a remote fallback keep it even if the local copy
# is gone.
continue
if not os.path.isdir(model_folder):
stale.append(idx)
else:
found = False
try:
prefix = f"image_{idx}."
for fname in os.listdir(model_folder):
if fname.startswith(prefix):
found = True
break
except OSError:
stale.append(idx)
continue
if not found:
stale.append(idx)
if stale:
for idx in reversed(stale):
images.pop(idx)
has_changes = True
logger.info(
"Pruned %d stale image entry(ies) for %s",
len(stale),
getattr(metadata, "model_name", model_hash),
)
return has_changes

View File

@@ -15,30 +15,64 @@ def get_lora_info(lora_name):
scanner = await ServiceRegistry.get_lora_scanner()
cache = await scanner.get_cached_data()
lora_name_normalized = lora_name.replace("\\", "/")
lora_name_no_ext = lora_name_normalized
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if lora_name_no_ext.lower().endswith(ext):
lora_name_no_ext = lora_name_no_ext[: -len(ext)]
break
has_path = "/" in lora_name_no_ext
basename = os.path.basename(lora_name_no_ext) if has_path else lora_name_no_ext
best_fallback = None
for item in cache.raw_data:
if item.get("file_name") == lora_name:
file_path = item.get("file_path")
if file_path:
# Check all lora roots including extra paths
all_roots = list(config.loras_roots or []) + list(
config.extra_loras_roots or []
file_name = item.get("file_name", "")
folder = item.get("folder", "")
file_name_no_ext = file_name
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if file_name_no_ext.lower().endswith(ext):
file_name_no_ext = file_name_no_ext[: -len(ext)]
break
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
if lora_name_no_ext not in (file_name_no_ext, path_name):
if has_path and file_name_no_ext == basename:
if folder and lora_name_no_ext.startswith(folder.replace("\\", "/") + "/"):
best_fallback = item
elif best_fallback is None:
best_fallback = item
continue
file_path = item.get("file_path")
if not file_path:
continue
all_roots = list(config.loras_roots or []) + list(
config.extra_loras_roots or []
)
for root in all_roots:
root = root.replace(os.sep, "/")
if file_path.startswith(root):
relative_path = os.path.relpath(file_path, root).replace(
os.sep, "/"
)
for root in all_roots:
root = root.replace(os.sep, "/")
if file_path.startswith(root):
relative_path = os.path.relpath(file_path, root).replace(
os.sep, "/"
)
# Get trigger words from civitai metadata
civitai = item.get("civitai", {})
trigger_words = (
civitai.get("trainedWords", []) if civitai else []
)
return relative_path, trigger_words
# If not found in any root, return path with trigger words from cache
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
trigger_words = (
civitai.get("trainedWords", []) if civitai else []
)
return relative_path, trigger_words
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
if best_fallback:
file_path = best_fallback.get("file_path")
if file_path:
civitai = best_fallback.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
return lora_name, []
try:
@@ -77,15 +111,54 @@ def get_lora_info_absolute(lora_name):
scanner = await ServiceRegistry.get_lora_scanner()
cache = await scanner.get_cached_data()
lora_name_normalized = lora_name.replace("\\", "/")
lora_name_no_ext = lora_name_normalized
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if lora_name_no_ext.lower().endswith(ext):
lora_name_no_ext = lora_name_no_ext[: -len(ext)]
break
has_path = "/" in lora_name_no_ext
basename = os.path.basename(lora_name_no_ext) if has_path else lora_name_no_ext
best_fallback = None
for item in cache.raw_data:
if item.get("file_name") == lora_name:
file_name = item.get("file_name", "")
folder = item.get("folder", "")
file_name_no_ext = file_name
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if file_name_no_ext.lower().endswith(ext):
file_name_no_ext = file_name_no_ext[: -len(ext)]
break
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
if lora_name_no_ext == file_name_no_ext:
file_path = item.get("file_path")
if file_path:
# Return absolute path directly
# Get trigger words from civitai metadata
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
if lora_name_no_ext == path_name:
file_path = item.get("file_path")
if file_path:
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
if has_path and file_name_no_ext == basename:
if folder and lora_name_no_ext.startswith(folder.replace("\\", "/") + "/"):
best_fallback = item
elif best_fallback is None:
best_fallback = item
if best_fallback:
file_path = best_fallback.get("file_path")
if file_path:
civitai = best_fallback.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
return lora_name, []
try:

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.0.5"
version = "1.0.9"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",

View File

@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""
Migrate metadata from old sidecar JSON format to LoRA Manager's metadata.json format.
This script automatically discovers model folders from LoRA Manager's settings.json,
finds JSON files with the same basename as model files (e.g., `model.json` for
`model.safetensors`), and migrates their content to the corresponding `.metadata.json` files.
Fields migrated:
- "activation text" → civitai.trainedWords (array of trigger words)
- "preferred weight" → usage_tips.strength (LoRA only, skipped for Checkpoint)
- "notes" → notes (user-defined notes)
Supported model types: LoRA, Checkpoint
Usage:
python scripts/migrate_legacy_metadata.py [--dry-run] [--verbose]
The script will:
1. Read settings.json to find all configured model folders
2. Recursively scan for model files (.safetensors, .ckpt, .pt, .pth, .bin)
3. Find corresponding legacy metadata JSON files
4. Migrate data to .metadata.json files
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
APP_NAME = "ComfyUI-LoRA-Manager"
MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin"}
SECRET_PATTERN = re.compile(r"(key|token|secret|password|auth|credential)", re.IGNORECASE)
def resolve_settings_path() -> Path:
repo_root = Path(__file__).parent.parent.resolve()
portable = repo_root / "settings.json"
if portable.exists():
payload = load_json(portable)
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
return portable
config_home = os.environ.get("XDG_CONFIG_HOME")
if config_home:
return Path(config_home).expanduser() / APP_NAME / "settings.json"
return Path.home() / ".config" / APP_NAME / "settings.json"
def load_json(path: Path) -> dict[str, Any]:
try:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return {}
except json.JSONDecodeError as exc:
logger.error(f"Invalid JSON in {path}: {exc}")
return {}
except OSError as exc:
logger.error(f"Cannot read {path}: {exc}")
return {}
def expand_path(value: str) -> str:
return str(Path(value).expanduser().resolve(strict=False))
def normalize_path_list(value: Any) -> list[str]:
if isinstance(value, str):
return [expand_path(value)] if value else []
if isinstance(value, list):
return [expand_path(item) for item in value if isinstance(item, str) and item]
return []
def dedupe(values: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for value in values:
if value not in seen:
result.append(value)
seen.add(value)
return result
def get_model_roots(settings: dict[str, Any]) -> dict[str, list[str]]:
roots: dict[str, list[str]] = {}
active_library = settings.get("active_library") or "default"
sources = [settings]
library = settings.get("libraries", {}).get(active_library)
if isinstance(library, dict):
sources.insert(0, library)
for source in sources:
folder_paths = source.get("folder_paths")
if isinstance(folder_paths, dict):
for key, value in folder_paths.items():
roots.setdefault(key, []).extend(normalize_path_list(value))
for default_key, folder_key in (
("default_lora_root", "loras"),
("default_checkpoint_root", "checkpoints"),
("default_embedding_root", "embeddings"),
("default_unet_root", "unet"),
):
value = settings.get(default_key)
if isinstance(value, str) and value:
roots.setdefault(folder_key, []).append(expand_path(value))
return {key: dedupe(values) for key, values in roots.items()}
def find_model_files(directory: Path) -> list[Path]:
model_files = []
for ext in MODEL_EXTENSIONS:
model_files.extend(directory.rglob(f"*{ext}"))
return model_files
def find_legacy_metadata(model_path: Path) -> Path | None:
base_name = model_path.stem
legacy_path = model_path.with_name(f"{base_name}.json")
if legacy_path.exists() and legacy_path.is_file():
return legacy_path
return None
def load_legacy_metadata(legacy_path: Path) -> dict[str, Any] | None:
try:
with open(legacy_path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in legacy file {legacy_path}: {e}")
return None
except Exception as e:
logger.error(f"Error reading legacy file {legacy_path}: {e}")
return None
def load_metadata(metadata_path: Path) -> dict[str, Any]:
if not metadata_path.exists():
return {}
try:
with open(metadata_path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.warning(f"Invalid JSON in metadata file {metadata_path}: {e}. Starting fresh.")
return {}
except Exception as e:
logger.error(f"Error reading metadata file {metadata_path}: {e}")
return {}
def save_metadata(metadata_path: Path, data: dict[str, Any], dry_run: bool = False) -> bool:
if dry_run:
logger.info(f"[DRY RUN] Would save metadata to: {metadata_path}")
return True
temp_path = metadata_path.with_suffix(".tmp")
try:
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(temp_path, metadata_path)
return True
except Exception as e:
logger.error(f"Error saving metadata to {metadata_path}: {e}")
if temp_path.exists():
try:
temp_path.unlink()
except:
pass
return False
def migrate_metadata(
legacy_data: dict[str, Any],
existing_metadata: dict[str, Any],
model_type: str
) -> dict[str, Any] | None:
metadata = existing_metadata.copy()
changes_made = False
if "civitai" not in metadata:
metadata["civitai"] = {}
activation_text = legacy_data.get("activation text")
if activation_text and isinstance(activation_text, str):
trigger_words = [
word.strip()
for word in activation_text.replace("\n", ",").split(",")
if word.strip()
]
if trigger_words:
existing_trained = metadata["civitai"].get("trainedWords", [])
if not isinstance(existing_trained, list):
existing_trained = []
merged = list(dict.fromkeys(existing_trained + trigger_words))
if merged != existing_trained:
metadata["civitai"]["trainedWords"] = merged
changes_made = True
logger.debug(f" Migrated activation text: {trigger_words}")
if model_type == "lora":
preferred_weight = legacy_data.get("preferred weight")
if preferred_weight is not None:
try:
weight_value = float(preferred_weight)
usage_tips_str = metadata.get("usage_tips", "{}")
if isinstance(usage_tips_str, str):
try:
usage_tips = json.loads(usage_tips_str)
except json.JSONDecodeError:
usage_tips = {}
elif isinstance(usage_tips_str, dict):
usage_tips = usage_tips_str
else:
usage_tips = {}
if "strength" not in usage_tips:
usage_tips["strength"] = weight_value
metadata["usage_tips"] = json.dumps(usage_tips, ensure_ascii=False)
changes_made = True
logger.debug(f" Migrated preferred weight: {weight_value}")
except (ValueError, TypeError) as e:
logger.warning(f" Could not parse preferred weight '{preferred_weight}': {e}")
else:
if legacy_data.get("preferred weight") is not None:
logger.debug(" Skipping 'preferred weight' for non-LoRA model")
notes = legacy_data.get("notes")
if notes and isinstance(notes, str) and notes.strip():
existing_notes = metadata.get("notes", "")
if not existing_notes:
metadata["notes"] = notes.strip()
changes_made = True
logger.debug(" Migrated notes")
elif notes.strip() not in existing_notes:
metadata["notes"] = f"{existing_notes}\n\n{notes.strip()}".strip()
changes_made = True
logger.debug(" Appended notes")
return metadata if changes_made else None
def process_model(model_path: Path, model_type: str, dry_run: bool = False) -> bool:
legacy_path = find_legacy_metadata(model_path)
if not legacy_path:
return True
logger.info(f"Processing: {model_path.name} ({model_type})")
logger.info(f" Found legacy metadata: {legacy_path.name}")
legacy_data = load_legacy_metadata(legacy_path)
if legacy_data is None:
return False
metadata_path = model_path.with_suffix(".metadata.json")
existing_metadata = load_metadata(metadata_path)
migrated = migrate_metadata(legacy_data, existing_metadata, model_type)
if migrated is None:
logger.info(" No changes needed (fields already exist or no migratable data)")
return True
if save_metadata(metadata_path, migrated, dry_run):
logger.info(f" ✓ Successfully migrated metadata to: {metadata_path.name}")
return True
else:
logger.error(" ✗ Failed to save metadata")
return False
def main() -> int:
parser = argparse.ArgumentParser(
description="Migrate legacy metadata JSON files to LoRA Manager's metadata.json format.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python scripts/migrate_legacy_metadata.py
python scripts/migrate_legacy_metadata.py --dry-run
python scripts/migrate_legacy_metadata.py --verbose
"""
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes without modifying any files"
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Enable verbose output"
)
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
settings_path = resolve_settings_path()
logger.info(f"Using settings: {settings_path}")
settings = load_json(settings_path)
if not settings:
logger.error("Could not load settings.json. Please ensure LoRA Manager is configured.")
return 1
roots = get_model_roots(settings)
if not roots:
logger.error("No model folders configured in settings.json.")
return 1
lora_roots = roots.get("loras", [])
checkpoint_roots = roots.get("checkpoints", []) + roots.get("unet", [])
all_roots = []
for root_list in [lora_roots, checkpoint_roots]:
for root in root_list:
path = Path(root)
if path.exists() and path.is_dir():
all_roots.append((path, "lora" if root in lora_roots else "checkpoint"))
if not all_roots:
logger.error("No valid model folders found.")
return 1
logger.info(f"Found {len(lora_roots)} LoRA root(s), {len(checkpoint_roots)} Checkpoint root(s)")
processed = 0
migrated = 0
errors = 0
skipped = 0
lora_count = 0
checkpoint_count = 0
for root_path, model_type in all_roots:
logger.info(f"Scanning: {root_path} ({model_type})")
model_files = find_model_files(root_path)
logger.debug(f" Found {len(model_files)} model files")
for model_path in model_files:
legacy_path = find_legacy_metadata(model_path)
if not legacy_path:
skipped += 1
continue
processed += 1
if process_model(model_path, model_type, dry_run=args.dry_run):
migrated += 1
if model_type == "lora":
lora_count += 1
else:
checkpoint_count += 1
else:
errors += 1
logger.info("\n" + "=" * 50)
logger.info("Migration Summary:")
logger.info(f" Models with legacy metadata: {processed}")
logger.info(f" Successfully migrated: {migrated}")
logger.info(f" - LoRA models: {lora_count}")
logger.info(f" - Checkpoint models: {checkpoint_count}")
logger.info(f" Errors: {errors}")
logger.info(f" Skipped (no legacy file): {skipped}")
if args.dry_run:
logger.info("\n [DRY RUN MODE - No files were modified]")
return 0 if errors == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -10,13 +10,14 @@
"C:/path/to/your/checkpoints_folder",
"C:/path/to/another/checkpoints_folder"
],
"unet": [
"C:/path/to/your/diffusion_models_folder",
"C:/path/to/another/diffusion_models_folder"
],
"embeddings": [
"C:/path/to/your/embeddings_folder",
"C:/path/to/another/embeddings_folder"
]
},
"example_images_open_mode": "system",
"example_images_local_root": "",
"example_images_open_uri_template": "",
"auto_organize_exclusions": []
}

View File

@@ -87,7 +87,7 @@
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-family: 'Font Awesome 6 Free', sans-serif;
font-weight: 900;
color: var(--lora-text);
font-size: 12px;

View File

@@ -22,6 +22,7 @@
transition: transform 160ms ease-out;
aspect-ratio: 896/1152; /* Preserve aspect ratio */
max-width: 260px; /* Base size */
min-width: 200px; /* Prevent cards from becoming too narrow */
width: 100%;
margin: 0 auto;
cursor: pointer;
@@ -328,7 +329,6 @@
}
.card-actions i {
margin-left: var(--space-1);
cursor: pointer;
color: white;
transition: opacity 0.2s, transform 0.15s ease;
@@ -370,7 +370,16 @@
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
}
/* 响应式设计 */
@media (max-width: 1200px) {
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.model-card {
max-width: 240px;
min-width: 180px;
}
}
@media (max-width: 768px) {
.card-grid {
grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */
@@ -378,6 +387,7 @@
.model-card {
max-width: 100%; /* Allow cards to fill available space on mobile */
min-width: 200px;
}
}
@@ -497,11 +507,91 @@
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
}
/* Version row — flex container for badges + version names */
.version-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 3px;
margin-top: 2px;
}
/* Badge + version-name binding: they wrap as a single unit */
.badge-version-unit {
display: inline-flex;
align-items: center;
gap: 3px;
min-width: 0;
flex-shrink: 0;
}
/* Medium density adjustments for version name */
.medium-density .version-name {
font-size: 0.8em;
}
.medium-density .badge-version-unit .version-name {
max-width: 90px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Compact density adjustments for version name */
.compact-density .version-name {
font-size: 0.75em;
}
.compact-density .badge-version-unit .version-name {
max-width: 70px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.medium-density .version-row {
gap: 2px;
}
/* HIGH / LOW badges — shown inline before version name in card footer */
.hl-badge {
display: inline-block;
font-size: 0.7em;
font-weight: 600;
line-height: 1.1;
padding: 1px 5px;
border-radius: var(--border-radius-xs);
border: 1px solid rgba(255, 255, 255, 0.2);
white-space: nowrap;
}
.hl-badge--high {
color: oklch(75% 0.12 230);
background: oklch(55% 0.15 240 / 0.25);
border-color: oklch(60% 0.18 250 / 0.3);
}
.hl-badge--low {
color: oklch(78% 0.10 185);
background: oklch(50% 0.10 190 / 0.25);
border-color: oklch(55% 0.12 195 / 0.3);
}
.medium-density .hl-badge {
font-size: 0.65em;
}
.compact-density .hl-badge {
font-size: 0.62em;
padding: 0px 4px;
}
/* Hide version-related elements when setting is disabled */
body.hide-card-version .civitai-version,
body.hide-card-version .hl-badge {
display: none;
}
/* Compact density adjustments for version name */
.compact-density .version-name {
font-size: 0.75em;
@@ -558,8 +648,13 @@
position: absolute;
box-sizing: border-box;
transition: transform 160ms ease-out;
margin: 0; /* Remove margins, positioning is handled by VirtualScroller */
width: 100%; /* Allow width to be set by the VirtualScroller */
margin: 0;
width: 100%;
}
/* Allow cards to grow beyond 260px in virtual scroll mode */
.virtual-scroll-item.model-card {
max-width: none;
}
.virtual-scroll-item:hover {
@@ -571,11 +666,11 @@
.card-grid.virtual-scroll {
display: block;
position: relative;
margin: 0 auto;
margin: 0; /* Remove auto margins - positioning handled by VirtualScroller leftOffset */
padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
height: auto;
width: 100%;
max-width: 1400px; /* Keep the max-width from original grid */
max-width: none; /* Remove max-width constraint - handled by VirtualScroller */
box-sizing: border-box; /* Include padding in width calculation */
overflow-x: hidden; /* Prevent horizontal overflow */
}

View File

@@ -22,6 +22,22 @@
gap: 1rem;
}
/* Left section: Logo + Navigation */
.header-left {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
/* Right section: Controls */
.header-right {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
/* Responsive header container for larger screens */
@media (min-width: 2150px) {
.header-container {
@@ -77,6 +93,7 @@
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
white-space: nowrap;
}
.nav-item:hover,
@@ -97,13 +114,99 @@
color: white;
}
/* Header search */
/* Header search - Centered with VS Code command palette style */
.header-search {
flex: 1;
max-width: 400px;
display: flex;
justify-content: center;
max-width: 600px;
margin: 0 auto;
transition: opacity 0.2s ease;
}
/* VS Code command palette style search container */
.header-search .search-container {
width: 100%;
max-width: 600px;
position: relative;
display: flex;
align-items: center;
background: var(--input-bg, var(--card-bg));
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm, 6px);
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.header-search .search-container:focus-within {
border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px var(--lora-accent);
}
.header-search input {
flex: 1;
width: 100%;
padding: 0.5rem 0.75rem;
padding-left: 2.25rem !important;
padding-right: 5rem !important;
border: none;
background: transparent;
color: var(--text-color);
font-size: 0.95rem;
outline: none;
}
.header-search input::placeholder {
color: var(--text-muted);
}
.header-search .search-icon {
position: absolute;
left: 0.75rem;
color: var(--text-muted);
font-size: 0.9rem;
pointer-events: none;
}
.header-search .search-options-toggle,
.header-search .search-filter-toggle {
position: absolute;
right: 0.5rem;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--border-radius-xs, 4px);
transition: all 0.2s ease;
}
.header-search .search-options-toggle {
right: 2.25rem;
}
.header-search .search-options-toggle:hover,
.header-search .search-filter-toggle:hover {
background: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
}
.header-search .filter-badge {
position: absolute;
top: 2px;
right: 2px;
width: 8px;
height: 8px;
background: var(--lora-accent);
border-radius: 50%;
font-size: 0;
}
/* Disabled state for header search */
.header-search.disabled {
opacity: 0.5;
@@ -247,44 +350,207 @@
opacity: 1;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.app-title {
display: none;
/* Hide text title on mobile */
/* Hamburger menu button - hidden by default */
.hamburger-menu-btn {
display: none;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.hamburger-menu-btn:hover {
background: var(--lora-accent);
color: white;
}
/* Hamburger dropdown menu */
.hamburger-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm, 6px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
padding: 0.5rem;
min-width: 160px;
z-index: var(--z-dropdown, 200);
}
.hamburger-dropdown.active {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.hamburger-dropdown .dropdown-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--border-radius-xs, 4px);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
white-space: nowrap;
}
.hamburger-dropdown .dropdown-item:hover {
background: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
}
.hamburger-dropdown .dropdown-item i {
width: 20px;
text-align: center;
}
.hamburger-dropdown .dropdown-divider {
height: 1px;
background: var(--border-color);
margin: 0.25rem 0;
}
/* Responsive: Early optimization at 1200px - reduce gaps and padding */
@media (max-width: 1200px) {
.header-container {
gap: 0.75rem;
padding: 0 12px;
}
.main-nav {
gap: 0.25rem;
}
.nav-item {
padding: 0.25rem 0.5rem;
font-size: 0.85rem;
}
.header-controls {
gap: 4px;
gap: 6px;
}
.header-controls>div {
width: 28px;
height: 28px;
.header-controls > div {
width: 30px;
height: 30px;
}
}
/* Responsive: Hide nav icons at 1100px to save space */
@media (max-width: 1100px) {
.nav-item {
gap: 0;
padding: 0.25rem 0.4rem;
}
.nav-item i {
display: none;
}
.header-search {
max-width: 450px;
}
}
@media (max-width: 950px) {
.app-title {
display: none !important;
}
.header-container {
padding: 0 10px;
gap: 0.5rem;
}
.header-controls {
display: none !important;
}
.hamburger-menu-btn {
display: flex !important;
}
.hamburger-dropdown {
display: none;
}
.hamburger-dropdown.active {
display: flex;
}
.header-search {
max-width: none;
margin: 0 0.5rem;
margin: 0;
flex: 1;
min-width: 200px;
}
.main-nav {
margin-right: 0.5rem;
gap: 0.25rem;
margin-right: 0;
}
.nav-item {
padding: 0.25rem 0.35rem;
font-size: 0.8rem;
}
}
/* For very small screens */
/* Responsive: Compact mode at 768px */
@media (max-width: 768px) {
.header-search input {
padding: 0.4rem 0.6rem;
padding-left: 2rem !important;
padding-right: 4.5rem !important;
font-size: 0.9rem;
}
.header-search .search-container {
border-radius: var(--border-radius-xs, 4px);
}
}
/* For very small screens - switch nav to icons only */
@media (max-width: 600px) {
.header-container {
padding: 0 8px;
gap: 0.4rem;
}
.main-nav {
display: none;
/* Hide navigation on very small screens */
display: flex;
gap: 0.15rem;
margin-right: 0;
}
.header-search {
flex: 1;
.nav-item {
padding: 0.25rem;
font-size: 0.75rem;
}
.nav-item span {
display: none;
}
.nav-item i {
display: block;
font-size: 1rem;
}
}
/* Position relative for hamburger menu positioning */
.header-right {
position: relative;
}

View File

@@ -255,25 +255,28 @@
transform: translateY(-2px);
}
/* File name copy styles */
.file-name-wrapper {
/* Editable inline field styles (file name, version name, etc.) */
.file-name-wrapper,
.version-name-wrapper {
display: flex;
align-items: center;
gap: 8px;
padding: 4px;
padding: 4px 0;
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
position: relative;
}
.file-name-content {
padding: 2px 4px;
.file-name-content,
.version-name-content {
padding: 2px 4px 2px 0;
border-radius: var(--border-radius-xs);
border: 1px solid transparent;
flex: 1;
}
.file-name-wrapper.editing .file-name-content {
.file-name-wrapper.editing .file-name-content,
.version-name-wrapper.editing .version-name-content {
border: 1px solid var(--lora-accent);
background: var(--bg-color);
outline: none;
@@ -283,7 +286,8 @@
.edit-model-name-btn,
.edit-file-name-btn,
.edit-base-model-btn,
.edit-model-description-btn {
.edit-model-description-btn,
.edit-version-name-btn {
background: transparent;
border: none;
color: var(--text-color);
@@ -299,9 +303,11 @@
.edit-file-name-btn.visible,
.edit-base-model-btn.visible,
.edit-model-description-btn.visible,
.edit-version-name-btn.visible,
.model-name-header:hover .edit-model-name-btn,
.file-name-wrapper:hover .edit-file-name-btn,
.base-model-display:hover .edit-base-model-btn,
.version-name-wrapper:hover .edit-version-name-btn,
.model-name-header:hover .edit-model-description-btn {
opacity: 0.5;
}
@@ -309,14 +315,16 @@
.edit-model-name-btn:hover,
.edit-file-name-btn:hover,
.edit-base-model-btn:hover,
.edit-model-description-btn:hover {
.edit-model-description-btn:hover,
.edit-version-name-btn:hover {
opacity: 0.8 !important;
background: rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] .edit-model-name-btn:hover,
[data-theme="dark"] .edit-file-name-btn:hover,
[data-theme="dark"] .edit-base-model-btn:hover {
[data-theme="dark"] .edit-base-model-btn:hover,
[data-theme="dark"] .edit-version-name-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
@@ -338,7 +346,7 @@
}
.base-model-content {
padding: 2px 4px;
padding: 2px 4px 2px 0;
border-radius: var(--border-radius-xs);
border: 1px solid transparent;
color: var(--text-color);

View File

@@ -374,11 +374,23 @@
background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
}
.version-action-disabled {
background: transparent;
border-color: var(--border-color);
color: var(--text-muted);
opacity: 0.6;
cursor: not-allowed;
}
.version-action:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.version-action-disabled-wrapper {
display: inline-flex;
}
.versions-loading-state,
.versions-empty,
.versions-error {

View File

@@ -0,0 +1,124 @@
.media-viewer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.3s ease;
}
.media-viewer-overlay.active {
background: rgba(0, 0, 0, 0.92);
}
.media-viewer-close {
position: fixed;
top: 16px;
right: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10001;
transition: background 0.2s ease;
opacity: 0;
}
.media-viewer-overlay.active .media-viewer-close {
opacity: 1;
}
.media-viewer-close:hover {
background: rgba(255, 255, 255, 0.25);
}
.media-viewer-content-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
max-width: 90vw;
max-height: 95vh;
cursor: default;
}
.media-viewer-media {
display: block;
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
.media-viewer-video {
max-height: 80vh;
}
.media-viewer-counter {
margin-top: 8px;
color: rgba(255, 255, 255, 0.5);
font-size: 0.85em;
text-align: center;
min-height: 1.2em;
}
.media-viewer-title {
margin-top: 4px;
color: rgba(255, 255, 255, 0.7);
font-size: 0.9em;
text-align: center;
max-width: 90vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-viewer-nav {
position: fixed;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 80px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
border: none;
color: #fff;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10001;
opacity: 0;
transition: opacity 0.2s ease, background 0.2s ease;
}
.media-viewer-overlay.active .media-viewer-nav {
opacity: 1;
}
.media-viewer-nav:hover {
background: rgba(255, 255, 255, 0.18);
}
.media-viewer-prev {
left: 16px;
}
.media-viewer-next {
right: 16px;
}

View File

@@ -41,6 +41,63 @@
text-align: center;
}
/* Section Headers */
.context-menu-section-header {
padding: 6px 12px 2px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
cursor: default;
user-select: none;
}
/* Submenu */
.context-menu-item.has-submenu {
position: relative;
justify-content: space-between;
}
.submenu-arrow {
margin-left: auto;
font-size: 10px;
width: auto !important;
}
.context-submenu {
position: absolute;
left: calc(100% - 4px);
top: -1px;
display: none;
background: var(--lora-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: 0;
min-width: 200px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
z-index: 1001;
backdrop-filter: blur(10px);
}
.context-submenu .context-menu-item {
white-space: nowrap;
margin: 0;
}
.context-submenu .context-menu-item:first-child {
padding-top: 9px;
}
.context-submenu .context-menu-item:last-child {
padding-bottom: 9px;
}
.context-submenu.flip-left {
left: auto;
right: 100%;
}
/* NSFW Level Selector */
.nsfw-level-selector {
position: fixed;

View File

@@ -33,6 +33,39 @@
animation: modalFadeIn 0.2s ease-out;
}
#resolveFilenameConflictsModal .confirmation-message {
color: var(--text-color);
margin: var(--space-2) 0;
font-size: 1em;
line-height: 1.5;
}
#resolveFilenameConflictsModal .resolve-conflicts-detail {
color: var(--text-color);
margin: var(--space-2) 0;
font-size: 0.95em;
line-height: 1.5;
}
#resolveFilenameConflictsModal .resolve-conflicts-detail code {
background: var(--lora-surface);
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
border: 1px solid var(--lora-border);
}
#resolveFilenameConflictsModal .resolve-conflicts-impact {
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
margin: var(--space-2) 0;
color: var(--text-color);
text-align: left;
line-height: 1.5;
}
.delete-model-info,
.exclude-model-info {
/* Update info display styling */

View File

@@ -1369,3 +1369,14 @@ input:checked + .toggle-slider:before {
background: var(--lora-error);
color: white;
}
/* Highlight animation for setting items targeted from Doctor actions */
@keyframes settings-highlight-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(from var(--lora-accent) r g b / 0.4); }
50% { box-shadow: 0 0 0 4px rgba(from var(--lora-accent) r g b / 0.2); }
}
.settings-setting-highlight {
animation: settings-highlight-pulse 1.5s ease-in-out 3;
border-radius: var(--border-radius-xs);
}

View File

@@ -4,15 +4,20 @@
justify-content: flex-start;
align-items: flex-start;
border-bottom: 1px solid var(--lora-border);
padding-bottom: 10px;
margin-bottom: 10px;
padding-bottom: var(--space-2);
margin-bottom: var(--space-3);
position: relative;
}
.recipe-modal-header h2 {
font-size: 1.4em; /* Reduced from default h2 size */
line-height: 1.3;
margin: 0;
max-height: 2.6em; /* Limit to 2 lines */
margin: 0 0 var(--space-1);
padding: var(--space-1);
border-radius: var(--border-radius-xs);
font-size: 1.5em;
font-weight: 600;
line-height: 1.2;
color: var(--text-color);
max-height: 2.8em;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
@@ -127,7 +132,7 @@
/* Recipe Tags styles */
.recipe-tags-container {
position: relative;
margin-top: 6px;
margin-top: 0;
margin-bottom: 10px;
}
@@ -225,6 +230,62 @@
overflow: hidden;
}
/* Recipe Header Actions */
.recipe-header-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
width: 100%;
margin-bottom: var(--space-1);
flex-shrink: 0;
min-height: 0;
}
.recipe-header-actions:empty {
display: none;
}
.recipe-source-url-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-weight: 500;
font-size: 0.9em;
transition: all 0.2s;
white-space: nowrap;
}
[data-theme="dark"] .recipe-source-url-btn {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
.recipe-source-url-btn:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent);
transform: translateY(-1px);
}
.recipe-source-url-btn i {
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
@media (max-height: 860px) {
.recipe-header-actions {
padding-bottom: 4px;
}
}
/* Top Section: Preview and Gen Params */
.recipe-top-section {
display: grid;
@@ -396,14 +457,54 @@
flex-direction: column;
}
.recipe-gen-params h3 {
margin-top: 0;
.gen-params-header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-2);
font-size: 1.2em;
color: var(--text-color);
padding-bottom: var(--space-1);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
gap: 8px;
}
.gen-params-header-row h3 {
margin: 0;
font-size: 1.2em;
color: var(--text-color);
}
/* Inline toggle for lora strip setting */
.lora-strip-toggle {
flex-shrink: 0;
gap: 6px;
}
.lora-strip-toggle .inline-toggle-label {
font-size: 0.78em;
white-space: nowrap;
opacity: 0.7;
transition: opacity 0.2s;
}
.lora-strip-toggle:hover .inline-toggle-label {
opacity: 1;
}
.lora-strip-toggle .toggle-switch {
width: 32px;
height: 16px;
}
.lora-strip-toggle .toggle-slider:before {
height: 10px;
width: 10px;
left: 3px;
bottom: 3px;
}
.lora-strip-toggle .toggle-switch input:checked + .toggle-slider:before {
transform: translateX(16px);
}
.gen-params-container {
@@ -1043,13 +1144,13 @@
}
.recipe-modal-header {
padding-bottom: 6px;
margin-bottom: 8px;
padding-bottom: var(--space-1);
margin-bottom: var(--space-2);
}
.recipe-modal-header h2 {
font-size: 1.25em;
max-height: 2.5em;
font-size: 1.3em;
max-height: 2.4em;
}
.recipe-tags-container {

View File

@@ -67,7 +67,6 @@
.early-access-info {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--card-bg);
@@ -97,7 +96,6 @@
.local-path {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--card-bg);

View File

@@ -271,11 +271,16 @@
/* Enhanced Sidebar Breadcrumb Styles */
.sidebar-breadcrumb-container {
margin-top: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
/* Sticky positioning to stick below header when scrolling
top: 0 means stick at the top of the scroll container (page-content)
which is at header height (48px) from the viewport */
position: sticky;
top: 0;
z-index: calc(var(--z-header) - 1);
}
.sidebar-breadcrumb-nav {
@@ -284,7 +289,6 @@
flex-wrap: wrap;
gap: 4px;
font-size: 0.85em;
padding: 0 8px;
}
.sidebar-breadcrumb-item {

View File

@@ -21,7 +21,7 @@
top: -54px;
z-index: calc(var(--z-header) - 1);
background: var(--bg-color);
padding: var(--space-2) 0;
padding: var(--space-1) 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
@@ -371,6 +371,14 @@
display: block;
}
/* Elevate the controls stacking context above breadcrumb nav when a dropdown is open,
so the dropdown menu isn't obscured. Only active when dropdown is shown to avoid
the entire controls bar (which can wrap to 2 rows on narrow viewports) covering
the sticky breadcrumb. */
.controls:has(.dropdown-group.active) {
z-index: var(--z-header);
}
.dropdown-item {
display: block;
padding: 6px 15px;
@@ -397,6 +405,33 @@
text-align: center;
}
/* Intermediate breakpoint: wrap controls-right to prevent overflow */
@media (max-width: 1500px) {
.actions {
flex-wrap: wrap;
gap: var(--space-2);
}
.action-buttons {
flex-wrap: wrap;
gap: var(--space-1);
}
.controls-right {
width: 100%;
justify-content: flex-end;
margin-top: 8px;
padding-left: 0;
}
/* Reduce button sizes to fit better */
.control-group button {
min-width: 80px;
padding: 4px 8px;
font-size: 0.8em;
}
}
@media (max-width: 768px) {
.actions {
flex-wrap: wrap;

View File

@@ -39,6 +39,7 @@
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
@import 'components/statistics.css'; /* Add statistics component */
@import 'components/sidebar.css'; /* Add sidebar component */
@import 'components/media-viewer.css';
.initialization-notice {
display: flex;

View File

@@ -422,8 +422,12 @@ export class BaseModelApiClient {
throw new Error('Failed to save metadata');
}
state.virtualScroller.updateSingleItem(filePath, data);
return response.json();
const result = await response.json();
state.virtualScroller.updateSingleItem(filePath, {
...data,
auto_tags: result.auto_tags,
});
return result;
} finally {
state.loadingManager.hide();
}
@@ -448,7 +452,10 @@ export class BaseModelApiClient {
const result = await response.json();
if (result.success && result.tags) {
state.virtualScroller.updateSingleItem(filePath, { tags: result.tags });
state.virtualScroller.updateSingleItem(filePath, {
tags: result.tags,
auto_tags: result.auto_tags,
});
}
return result;
@@ -978,6 +985,16 @@ export class BaseModelApiClient {
});
}
if (pageState.filters.autoTags && Object.keys(pageState.filters.autoTags).length > 0) {
Object.entries(pageState.filters.autoTags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('auto_tag_include', tag);
} else if (state === 'exclude') {
params.append('auto_tag_exclude', tag);
}
});
}
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
// Check for empty wildcard marker - if present, no models should match
const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__';

View File

@@ -15,6 +15,7 @@ const RECIPE_ENDPOINTS = {
move: '/api/lm/recipe/move',
moveBulk: '/api/lm/recipes/move-bulk',
bulkDelete: '/api/lm/recipes/bulk-delete',
repairBulk: '/api/lm/recipes/repair-bulk',
};
const RECIPE_SIDEBAR_CONFIG = {
@@ -557,6 +558,38 @@ export class RecipeSidebarApiClient {
};
}
async repairBulkModels(filePaths) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
const recipeIds = filePaths
.map((path) => extractRecipeId(path))
.filter((id) => !!id);
if (recipeIds.length === 0) {
throw new Error('No recipe IDs could be derived from file paths');
}
const response = await fetch(this.apiConfig.endpoints.repairBulk, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_ids: recipeIds,
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to repair recipes');
}
return result;
}
async bulkDeleteModels(filePaths) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');

View File

@@ -3,32 +3,113 @@ export class BaseContextMenu {
this.menu = document.getElementById(menuId);
this.cardSelector = cardSelector;
this.currentCard = null;
this.submenuTimeout = null;
this.openSubmenu = null;
if (!this.menu) {
console.error(`Context menu element with ID ${menuId} not found`);
return;
}
this.init();
}
init() {
// Hide menu on regular clicks
document.addEventListener('click', () => this.hideMenu());
// Hide menu when clicking outside
document.addEventListener('click', (e) => {
if (!this.menu.contains(e.target)) {
this.hideMenu();
}
});
// Handle menu item clicks
// Handle menu item clicks (including submenu items)
this.menu.addEventListener('click', (e) => {
const menuItem = e.target.closest('.context-menu-item');
if (!menuItem || !this.currentCard) return;
// Ignore clicks on submenu trigger (has-submenu parent)
if (menuItem.classList.contains('has-submenu')) return;
const action = menuItem.dataset.action;
if (!action) return;
this.handleMenuAction(action, menuItem);
this.hideMenu();
});
// Submenu hover handling
// Use mouseover/mouseout (which bubble) with relatedTarget checks
// to reliably detect crossing the .has-submenu boundary
this.menu.addEventListener('mouseover', (e) => {
const trigger = e.target.closest('.has-submenu');
if (!trigger) return;
// Only act when entering from outside this trigger's tree
if (e.relatedTarget && trigger.contains(e.relatedTarget)) return;
this._openSubmenu(trigger);
});
this.menu.addEventListener('mouseout', (e) => {
const trigger = e.target.closest('.has-submenu');
if (!trigger) return;
// Only close when leaving the trigger's tree entirely
if (e.relatedTarget && trigger.contains(e.relatedTarget)) return;
this._scheduleSubmenuClose(trigger);
});
}
_openSubmenu(trigger) {
// Clear any pending close
if (this.submenuTimeout) {
clearTimeout(this.submenuTimeout);
this.submenuTimeout = null;
}
// Hide any previously open submenu
if (this.openSubmenu && this.openSubmenu !== trigger) {
this._hideSubmenu(this.openSubmenu);
}
const submenu = trigger.querySelector('.context-submenu');
if (!submenu) return;
submenu.style.display = 'block';
this.openSubmenu = trigger;
this._positionSubmenu(submenu);
}
_scheduleSubmenuClose(trigger) {
this.submenuTimeout = setTimeout(() => {
this._hideSubmenu(trigger);
this.submenuTimeout = null;
}, 250);
}
_hideSubmenu(trigger) {
const submenu = trigger.querySelector('.context-submenu');
if (submenu) {
submenu.style.display = 'none';
submenu.classList.remove('flip-left');
}
if (this.openSubmenu === trigger) {
this.openSubmenu = null;
}
}
_positionSubmenu(submenu) {
const submenuRect = submenu.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth;
if (submenuRect.right > viewportWidth) {
submenu.classList.add('flip-left');
} else {
submenu.classList.remove('flip-left');
}
}
handleMenuAction(action, menuItem) {
// Override in subclass
console.warn('handleMenuAction not implemented');
@@ -40,34 +121,41 @@ export class BaseContextMenu {
// Get menu dimensions
const menuRect = this.menu.getBoundingClientRect();
// Get viewport dimensions
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
// Calculate position
let finalX = x;
let finalY = y;
// Ensure menu doesn't go offscreen right
if (x + menuRect.width > viewportWidth) {
finalX = x - menuRect.width;
}
// Ensure menu doesn't go offscreen bottom
if (y + menuRect.height > viewportHeight) {
finalY = y - menuRect.height;
}
// Position menu
this.menu.style.left = `${finalX}px`;
this.menu.style.top = `${finalY}px`;
}
hideMenu() {
if (this.submenuTimeout) {
clearTimeout(this.submenuTimeout);
this.submenuTimeout = null;
}
if (this.openSubmenu) {
this._hideSubmenu(this.openSubmenu);
}
if (this.menu) {
this.menu.style.display = 'none';
}
this.currentCard = null;
}
}
}

View File

@@ -4,6 +4,7 @@ import { bulkManager } from '../../managers/BulkManager.js';
import { updateElementText, translate } from '../../utils/i18nHelpers.js';
import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js';
import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
export class BulkContextMenu extends BaseContextMenu {
constructor() {
@@ -40,6 +41,11 @@ export class BulkContextMenu extends BaseContextMenu {
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
const repairMetadataItem = this.menu.querySelector('[data-action="repair-metadata"]');
if (repairMetadataItem) {
repairMetadataItem.style.display = config.repairMetadata ? 'flex' : 'none';
}
if (sendToWorkflowAppendItem) {
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
@@ -50,6 +56,14 @@ export class BulkContextMenu extends BaseContextMenu {
if (copyAllItem) {
copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
}
// Submenu parent visibility
const sendToWorkflowSubmenu = this.menu.querySelector('[data-has-submenu="send-to-workflow"]');
if (sendToWorkflowSubmenu) {
const hasWorkflowActions = config.sendToWorkflow || config.copyAll;
sendToWorkflowSubmenu.style.display = hasWorkflowActions ? 'flex' : 'none';
}
if (refreshAllItem) {
refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
}
@@ -74,44 +88,92 @@ export class BulkContextMenu extends BaseContextMenu {
if (setContentRatingItem) {
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
}
const setFavoriteItem = this.menu.querySelector('[data-action="set-favorite"]');
if (setFavoriteItem && config.setFavorite) {
setFavoriteItem.style.display = 'flex';
const total = state.selectedModels.size;
const favoritedCount = this.countFavoritedInSelection();
const allFavorited = total > 0 && favoritedCount === total;
const icon = setFavoriteItem.querySelector('i');
const label = setFavoriteItem.querySelector('span');
if (allFavorited) {
if (icon) { icon.className = 'far fa-star'; }
if (label) { label.textContent = translate('loras.bulkOperations.unfavorite'); }
} else {
if (icon) { icon.className = 'fas fa-star'; }
if (label) {
label.textContent = favoritedCount > 0
? translate('loras.bulkOperations.setFavoriteCount', { favorited: favoritedCount, total })
: translate('loras.bulkOperations.setFavorite');
}
}
} else if (setFavoriteItem) {
setFavoriteItem.style.display = 'none';
}
if (downloadMissingLorasItem) {
// Only show for recipes page
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
}
const downloadExampleImagesItem = this.menu.querySelector('[data-action="download-example-images"]');
if (downloadExampleImagesItem) {
// Show on model pages (loras, checkpoints, embeddings), hide on recipes
const modelPages = ['loras', 'checkpoints', 'embeddings'];
downloadExampleImagesItem.style.display = modelPages.includes(currentModelType) ? 'flex' : 'none';
}
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
if (skipMetadataRefreshItem && resumeMetadataRefreshItem) {
const skipCount = this.countSkipStatus(true);
const resumeCount = this.countSkipStatus(false);
const totalCount = skipCount + resumeCount;
if (skipCount === totalCount) {
if (!config.skipMetadataRefresh) {
skipMetadataRefreshItem.style.display = 'none';
resumeMetadataRefreshItem.style.display = 'flex';
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
'loras.bulkOperations.resumeMetadataRefresh'
);
} else if (resumeCount === totalCount) {
skipMetadataRefreshItem.style.display = 'flex';
resumeMetadataRefreshItem.style.display = 'none';
skipMetadataRefreshItem.querySelector('span').textContent = translate(
'loras.bulkOperations.skipMetadataRefresh'
);
} else {
skipMetadataRefreshItem.style.display = 'flex';
resumeMetadataRefreshItem.style.display = 'flex';
skipMetadataRefreshItem.querySelector('span').textContent = translate(
'loras.bulkOperations.skipMetadataRefreshCount',
{ count: resumeCount }
);
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
'loras.bulkOperations.resumeMetadataRefreshCount',
{ count: skipCount }
);
const skipCount = this.countSkipStatus(true);
const resumeCount = this.countSkipStatus(false);
const totalCount = skipCount + resumeCount;
if (skipCount === totalCount) {
skipMetadataRefreshItem.style.display = 'none';
resumeMetadataRefreshItem.style.display = 'flex';
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
'loras.bulkOperations.resumeMetadataRefresh'
);
} else if (resumeCount === totalCount) {
skipMetadataRefreshItem.style.display = 'flex';
resumeMetadataRefreshItem.style.display = 'none';
skipMetadataRefreshItem.querySelector('span').textContent = translate(
'loras.bulkOperations.skipMetadataRefresh'
);
} else {
skipMetadataRefreshItem.style.display = 'flex';
resumeMetadataRefreshItem.style.display = 'flex';
skipMetadataRefreshItem.querySelector('span').textContent = translate(
'loras.bulkOperations.skipMetadataRefreshCount',
{ count: resumeCount }
);
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
'loras.bulkOperations.resumeMetadataRefreshCount',
{ count: skipCount }
);
}
}
}
// Hide empty sections
this.menu.querySelectorAll('.context-menu-section').forEach(section => {
const items = Array.from(section.querySelectorAll('.context-menu-item'))
.filter(item => !item.closest('.context-submenu'));
const allHidden = items.length > 0 && items.every(item => item.style.display === 'none');
section.style.display = allHidden ? 'none' : '';
});
}
updateSelectedCountHeader() {
@@ -138,6 +200,20 @@ export class BulkContextMenu extends BaseContextMenu {
return count;
}
countFavoritedInSelection() {
let count = 0;
for (const filePath of state.selectedModels) {
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(filePath)
: filePath.replace(/["\\]/g, '\\$&');
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (card && card.dataset.favorite === 'true') {
count++;
}
}
return count;
}
showMenu(x, y, card) {
this.updateMenuItemsForModelType();
this.updateSelectedCountHeader();
@@ -185,9 +261,20 @@ export class BulkContextMenu extends BaseContextMenu {
case 'delete-all':
bulkManager.showBulkDeleteModal();
break;
case 'repair-metadata':
bulkManager.repairSelectedRecipes();
break;
case 'set-favorite': {
const allFavorited = this.countFavoritedInSelection() === state.selectedModels.size;
bulkManager.setBulkFavorites(!allFavorited);
break;
}
case 'download-missing-loras':
this.handleDownloadMissingLoras();
break;
case 'download-example-images':
this.handleDownloadExampleImages();
break;
case 'clear':
bulkManager.clearSelection();
break;
@@ -230,4 +317,31 @@ export class BulkContextMenu extends BaseContextMenu {
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
}
async handleDownloadExampleImages() {
if (state.selectedModels.size === 0) {
return;
}
const hashes = new Set();
for (const filePath of state.selectedModels) {
const escapedPath = CSS.escape(filePath);
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (card?.dataset?.sha256) {
hashes.add(card.dataset.sha256);
}
}
if (hashes.size === 0) {
showToast('No valid model hashes found in selection', {}, 'warning');
return;
}
try {
const apiClient = getModelApiClient();
await apiClient.downloadExampleImages([...hashes]);
} catch (error) {
console.error('Bulk download example images failed:', error);
}
}
}

View File

@@ -129,6 +129,126 @@ export class HeaderManager {
// Hide search functionality on Statistics page
this.updateHeaderForPage();
// Initialize hamburger menu for mobile
this.initializeHamburgerMenu();
}
initializeHamburgerMenu() {
const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
const hamburgerDropdown = document.getElementById('hamburgerDropdown');
if (!hamburgerBtn || !hamburgerDropdown) return;
// Toggle dropdown on hamburger button click
hamburgerBtn.addEventListener('click', (e) => {
e.stopPropagation();
hamburgerDropdown.classList.toggle('active');
const icon = hamburgerBtn.querySelector('i');
if (hamburgerDropdown.classList.contains('active')) {
icon.classList.remove('fa-bars');
icon.classList.add('fa-times');
} else {
icon.classList.remove('fa-times');
icon.classList.add('fa-bars');
}
});
// Handle dropdown item clicks
const dropdownItems = hamburgerDropdown.querySelectorAll('.dropdown-item');
dropdownItems.forEach(item => {
item.addEventListener('click', (e) => {
const action = item.dataset.action;
this.handleHamburgerAction(action);
hamburgerDropdown.classList.remove('active');
const icon = hamburgerBtn.querySelector('i');
icon.classList.remove('fa-times');
icon.classList.add('fa-bars');
});
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!hamburgerDropdown.contains(e.target) && !hamburgerBtn.contains(e.target)) {
hamburgerDropdown.classList.remove('active');
const icon = hamburgerBtn.querySelector('i');
if (icon) {
icon.classList.remove('fa-times');
icon.classList.add('fa-bars');
}
}
});
// Update theme icon in hamburger menu based on current theme
this.updateHamburgerThemeIcon();
}
handleHamburgerAction(action) {
switch (action) {
case 'theme':
if (typeof toggleTheme === 'function') {
const newTheme = toggleTheme();
// Update theme toggle in header if it exists
const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) {
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
themeToggle.classList.add(`theme-${newTheme}`);
this.updateThemeTooltip(themeToggle, newTheme);
}
this.updateHamburgerThemeIcon();
}
break;
case 'settings':
if (window.settingsManager) {
window.settingsManager.toggleSettings();
}
break;
case 'help':
const helpToggle = document.getElementById('helpToggleBtn');
if (helpToggle) {
helpToggle.click();
}
break;
case 'notifications':
updateService.toggleUpdateModal();
break;
case 'support':
if (window.modalManager) {
window.modalManager.toggleModal('supportModal');
renderSupporters().catch(error => {
console.error('Error loading supporters:', error);
});
}
break;
}
}
updateHamburgerThemeIcon() {
const themeItem = document.querySelector('.dropdown-item[data-action="theme"]');
if (!themeItem) return;
const currentTheme = getStorageItem('theme') || 'auto';
const icon = themeItem.querySelector('i');
const text = themeItem.querySelector('span');
if (icon) {
icon.classList.remove('fa-moon', 'fa-sun', 'fa-adjust');
if (currentTheme === 'light') {
icon.classList.add('fa-sun');
} else if (currentTheme === 'dark') {
icon.classList.add('fa-moon');
} else {
icon.classList.add('fa-adjust');
}
}
// Update text based on current theme
if (text) {
const key = currentTheme === 'light' ? 'header.theme.switchToDark' :
currentTheme === 'dark' ? 'header.theme.switchToLight' :
'header.theme.toggle';
updateElementAttribute(themeItem, 'aria-label', key, {}, '');
}
}
updateHeaderForPage() {

View File

@@ -2,10 +2,11 @@
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
import { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { openMediaViewer } from './shared/MediaViewer.js';
const ALLOWED_GEN_PARAM_KEYS = new Set([
'prompt',
@@ -104,6 +105,7 @@ class RecipeModal {
init() {
this.setupCopyButtons();
this.setupStripLoraToggle();
this.setupPromptEditors();
// Set up tooltip positioning handlers after DOM is ready
document.addEventListener('DOMContentLoaded', () => {
@@ -112,6 +114,23 @@ class RecipeModal {
// Set up document click handler to close edit fields
document.addEventListener('click', (event) => {
const recipeModal = document.getElementById('recipeModal');
if (recipeModal && recipeModal.style.display !== 'none') {
const mediaEl = event.target.closest('.recipe-preview-media');
if (mediaEl && mediaEl.tagName) {
event.stopPropagation();
const isVideo = mediaEl.tagName === 'VIDEO';
const url = mediaEl.src || mediaEl.currentSrc;
if (url) {
openMediaViewer(url, {
type: isVideo ? 'video' : 'image',
title: document.getElementById('recipeModalTitle')?.textContent || ''
});
}
return;
}
}
// Handle title edit
const titleEditor = document.getElementById('recipeTitleEditor');
if (titleEditor && titleEditor.classList.contains('active') &&
@@ -364,6 +383,7 @@ class RecipeModal {
this.syncGenerationParams(hydratedRecipe.gen_params);
this.syncResourcesSection(hydratedRecipe);
this.syncSourceUrlAction();
// Show the modal
modalManager.showModal('recipeModal');
@@ -496,6 +516,7 @@ class RecipeModal {
} else {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
}
this.syncSourceUrlAction();
}
getPreviewMediaUrl(recipe = {}) {
@@ -563,6 +584,30 @@ class RecipeModal {
}
}
syncSourceUrlAction() {
const actionsContainer = document.getElementById('recipeHeaderActions');
if (!actionsContainer) {
return;
}
actionsContainer.innerHTML = '';
const sourcePath = this.currentRecipe?.source_path || '';
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://');
if (!isValidUrl) {
return;
}
const btn = document.createElement('button');
btn.className = 'recipe-source-url-btn';
btn.title = sourcePath;
btn.innerHTML = '<i class="fas fa-globe"></i> Open Source URL';
btn.addEventListener('click', () => {
window.open(sourcePath, '_blank');
});
actionsContainer.appendChild(btn);
}
syncTagsDisplay(tags) {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (!tagsContainer) {
@@ -1297,6 +1342,7 @@ class RecipeModal {
// Update source URL in the UI
this.commitField('source_path');
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
this.syncSourceUrlAction();
// Update the current recipe object
this.currentRecipe.source_path = newSourceUrl;
@@ -1332,14 +1378,20 @@ class RecipeModal {
if (copyPromptBtn) {
copyPromptBtn.addEventListener('click', () => {
const promptText = this.currentRecipe?.gen_params?.prompt || '';
let promptText = this.currentRecipe?.gen_params?.prompt || '';
if (this.shouldStripLoraOnCopy()) {
promptText = RecipeModal.stripLoraTags(promptText);
}
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
});
}
if (copyNegativePromptBtn) {
copyNegativePromptBtn.addEventListener('click', () => {
const negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
let negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
if (this.shouldStripLoraOnCopy()) {
negativePromptText = RecipeModal.stripLoraTags(negativePromptText);
}
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
});
}
@@ -1359,6 +1411,43 @@ class RecipeModal {
}
}
/**
* Strip <lora:...> tags from prompt text and clean up residual punctuation/whitespace.
* Handles both unescaped (<lora:...>) and HTML-escaped (&lt;lora:...&gt;) variants.
* 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();
}
shouldStripLoraOnCopy() {
const toggle = document.getElementById('stripLoraOnCopyToggle');
return toggle ? toggle.checked : false;
}
setupStripLoraToggle() {
const toggle = document.getElementById('stripLoraOnCopyToggle');
if (!toggle) return;
const stored = getStorageItem('strip_lora_on_copy');
if (stored !== null) {
toggle.checked = stored === true;
}
toggle.addEventListener('change', () => {
const checked = toggle.checked;
setStorageItem('strip_lora_on_copy', checked);
state.global.settings.strip_lora_on_copy = checked;
});
}
// Fetch recipe syntax from backend and copy to clipboard
async fetchAndCopyRecipeSyntax() {
if (!this.recipeId) {

View File

@@ -166,17 +166,6 @@ export class PageControls {
});
});
// Handle quick refresh option
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
if (quickRefreshOption) {
quickRefreshOption.addEventListener('click', (e) => {
e.stopPropagation();
this.refreshModels(false);
// Close the dropdown
document.querySelector('.dropdown-group.active')?.classList.remove('active');
});
}
// Handle full rebuild option
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
if (fullRebuildOption) {
@@ -829,4 +818,4 @@ export class PageControls {
this.sidebarManager.cleanup();
}
}
}
}

View File

@@ -0,0 +1,204 @@
let activeViewer = null;
function createMediaElement(item) {
const { url, type = 'image' } = item;
if (type === 'video') {
const el = document.createElement('video');
el.controls = true;
el.autoplay = true;
el.loop = true;
el.muted = true;
el.className = 'media-viewer-media media-viewer-video';
el.src = url;
return el;
}
const el = document.createElement('img');
el.className = 'media-viewer-media media-viewer-image';
el.src = url;
el.alt = 'Full size preview';
el.draggable = false;
return el;
}
function preloadAdjacent(items, index) {
[index - 1, index + 1].forEach(i => {
if (i >= 0 && i < items.length && items[i].type !== 'video') {
const preload = new Image();
preload.src = items[i].url;
}
});
}
export function openMediaViewer(arg1, arg2, arg3) {
closeMediaViewer();
let items, currentIndex, title = '';
if (Array.isArray(arg1)) {
items = arg1;
currentIndex = typeof arg2 === 'number' ? arg2 : 0;
title = (arg3 && arg3.title) || '';
} else {
items = [{ url: arg1, type: (arg2 && arg2.type) || 'image' }];
currentIndex = 0;
title = (arg2 && arg2.title) || '';
}
if (currentIndex < 0 || currentIndex >= items.length) currentIndex = 0;
const overlay = document.createElement('div');
overlay.className = 'media-viewer-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-label', title || 'Media viewer');
const closeBtn = document.createElement('button');
closeBtn.className = 'media-viewer-close';
closeBtn.innerHTML = '<i class="fas fa-times"></i>';
closeBtn.title = 'Close (Esc)';
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
closeMediaViewer();
});
const contentContainer = document.createElement('div');
contentContainer.className = 'media-viewer-content-container';
let mediaElement = createMediaElement(items[currentIndex]);
contentContainer.appendChild(mediaElement);
const hasNavigation = items.length > 1;
const counter = document.createElement('div');
counter.className = 'media-viewer-counter';
counter.textContent = hasNavigation ? `${currentIndex + 1} / ${items.length}` : '';
contentContainer.appendChild(counter);
if (title) {
const titleBar = document.createElement('div');
titleBar.className = 'media-viewer-title';
titleBar.textContent = title;
contentContainer.appendChild(titleBar);
}
let prevBtn, nextBtn;
if (hasNavigation) {
prevBtn = document.createElement('button');
prevBtn.className = 'media-viewer-nav media-viewer-prev';
prevBtn.innerHTML = '<i class="fas fa-chevron-left"></i>';
prevBtn.title = 'Previous (←)';
nextBtn = document.createElement('button');
nextBtn.className = 'media-viewer-nav media-viewer-next';
nextBtn.innerHTML = '<i class="fas fa-chevron-right"></i>';
nextBtn.title = 'Next (→)';
const navigate = (delta) => {
const newIndex = (currentIndex + delta + items.length) % items.length;
currentIndex = newIndex;
const oldMedia = contentContainer.querySelector('.media-viewer-media');
const newMedia = createMediaElement(items[currentIndex]);
if (oldMedia) {
if (oldMedia.tagName === 'VIDEO') {
oldMedia.pause();
oldMedia.src = '';
}
oldMedia.replaceWith(newMedia);
}
mediaElement = newMedia;
counter.textContent = `${currentIndex + 1} / ${items.length}`;
preloadAdjacent(items, currentIndex);
};
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(1); });
overlay.appendChild(prevBtn);
overlay.appendChild(nextBtn);
}
overlay.appendChild(closeBtn);
overlay.appendChild(contentContainer);
document.body.appendChild(overlay);
requestAnimationFrame(() => {
overlay.classList.add('active');
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
closeMediaViewer();
}
});
const keyHandler = (e) => {
if (e.key === 'Escape') {
closeMediaViewer();
return;
}
if (hasNavigation) {
if (e.key === 'ArrowLeft') {
e.stopPropagation();
e.preventDefault();
prevBtn.click();
return;
}
if (e.key === 'ArrowRight') {
e.stopPropagation();
e.preventDefault();
nextBtn.click();
return;
}
}
};
document.addEventListener('keydown', keyHandler, true);
activeViewer = { overlay, keyHandler };
preloadAdjacent(items, currentIndex);
if (items[currentIndex].type === 'video') {
const recipeVideo = document.getElementById('recipeModalVideo');
if (recipeVideo && !recipeVideo.paused) {
recipeVideo.pause();
}
}
}
export function closeMediaViewer() {
if (!activeViewer) return;
const { overlay, keyHandler } = activeViewer;
const video = overlay.querySelector('video');
if (video) {
video.pause();
video.src = '';
}
const img = overlay.querySelector('img');
if (img) {
img.src = '';
}
document.removeEventListener('keydown', keyHandler, true);
overlay.classList.remove('active');
overlay.addEventListener('transitionend', () => {
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}, { once: true });
setTimeout(() => {
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}, 500);
activeViewer = null;
}
export function isMediaViewerOpen() {
return activeViewer !== null;
}

View File

@@ -166,7 +166,9 @@ async function toggleFavorite(card) {
function handleSendToWorkflow(card, replaceMode, modelType) {
if (modelType === MODEL_TYPES.LORA) {
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const loraSyntax = buildLoraSyntax(card.dataset.file_name, usageTips);
const folder = card.dataset.folder || '';
const loraName = folder ? `${folder}/${card.dataset.file_name}` : card.dataset.file_name;
const loraSyntax = buildLoraSyntax(loraName, usageTips);
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
const modelPath = card.dataset.filepath;
@@ -644,8 +646,23 @@ export function createModelCard(model, modelType) {
<div class="card-footer">
<div class="model-info">
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '&quot;')}">${getDisplayName(model)}</span>
<div>
${model.civitai?.name ? `<span class="version-name">${model.civitai.name}</span>` : ''}
<div class="version-row">
${(() => {
const autoTags = model.auto_tags || [];
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
const hasVersionName = model.civitai?.name;
if (!hlTags.length && !hasVersionName) return '';
const density = state.global.settings.display_density || 'default';
const shortLabels = density === 'medium' || density === 'compact';
const badges = 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>` : '';
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>` : ''}
</div>
</div>

View File

@@ -66,6 +66,12 @@ function updateModalFilePathReferences(newFilePath) {
fileNameContent.setAttribute('data-file-path', newFilePath);
}
const versionNameContent = scopedQuery('.version-name-content');
if (versionNameContent && versionNameContent.dataset) {
versionNameContent.dataset.filePath = newFilePath;
versionNameContent.setAttribute('data-file-path', newFilePath);
}
const editTagsBtn = scopedQuery('.edit-tags-btn');
if (editTagsBtn) {
editTagsBtn.dataset.filePath = newFilePath;
@@ -516,3 +522,127 @@ export function setupFileNameEditing(filePath) {
editBtn.classList.remove('visible');
}
}
/**
* Set up version name editing functionality
* @param {string} filePath - File path
*/
export function setupVersionNameEditing(filePath) {
const versionNameContent = document.querySelector('.version-name-content');
const editBtn = document.querySelector('.edit-version-name-btn');
if (!versionNameContent || !editBtn) return;
// Store the file path in a data attribute for later use
versionNameContent.dataset.filePath = filePath;
// Show edit button on hover
const versionNameWrapper = document.querySelector('.version-name-wrapper');
versionNameWrapper.addEventListener('mouseenter', () => {
editBtn.classList.add('visible');
});
versionNameWrapper.addEventListener('mouseleave', () => {
if (!versionNameWrapper.classList.contains('editing')) {
editBtn.classList.remove('visible');
}
});
// Handle edit button click
editBtn.addEventListener('click', () => {
versionNameWrapper.classList.add('editing');
versionNameContent.setAttribute('contenteditable', 'true');
// Store original value for comparison later
versionNameContent.dataset.originalValue = versionNameContent.textContent.trim();
versionNameContent.focus();
// Place cursor at the end
const range = document.createRange();
const sel = window.getSelection();
if (versionNameContent.childNodes.length > 0) {
range.setStart(versionNameContent.childNodes[0], versionNameContent.textContent.length);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
editBtn.classList.add('visible');
});
// Handle keyboard events in edit mode
versionNameContent.addEventListener('keydown', function(e) {
if (!this.getAttribute('contenteditable')) return;
if (e.key === 'Enter') {
e.preventDefault();
this.blur(); // Trigger save on Enter
} else if (e.key === 'Escape') {
e.preventDefault();
// Restore original value
this.textContent = this.dataset.originalValue;
exitEditMode();
}
});
// Limit version name length
versionNameContent.addEventListener('input', function() {
if (!this.getAttribute('contenteditable')) return;
if (this.textContent.length > 100) {
this.textContent = this.textContent.substring(0, 100);
// Place cursor at the end
const range = document.createRange();
const sel = window.getSelection();
range.setStart(this.childNodes[0], 100);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
showToast('toast.models.nameTooLong', {}, 'warning');
}
});
// Handle focus out - save changes
versionNameContent.addEventListener('blur', async function() {
if (!this.getAttribute('contenteditable')) return;
const newVersionName = this.textContent.trim();
const originalValue = this.dataset.originalValue;
// Basic validation
if (!newVersionName) {
// Restore original value if empty
this.textContent = originalValue;
showToast('toast.models.nameCannotBeEmpty', {}, 'error');
exitEditMode();
return;
}
if (newVersionName === originalValue) {
// No changes, just exit edit mode
exitEditMode();
return;
}
try {
// Resolve current file path from modal state
const filePath = getActiveModalFilePath(this.dataset.filePath);
await getModelApiClient().saveModelMetadata(filePath, { civitai: { name: newVersionName } });
showToast('toast.models.nameUpdatedSuccessfully', {}, 'success');
} catch (error) {
console.error('Error updating version name:', error);
this.textContent = originalValue; // Restore original version name
showToast('toast.models.nameUpdateFailed', {}, 'error');
} finally {
exitEditMode();
}
});
function exitEditMode() {
versionNameContent.removeAttribute('contenteditable');
versionNameWrapper.classList.remove('editing');
editBtn.classList.remove('visible');
}
}

View File

@@ -11,7 +11,8 @@ import { setupTabSwitching } from './ModelDescription.js';
import {
setupModelNameEditing,
setupBaseModelEditing,
setupFileNameEditing
setupFileNameEditing,
setupVersionNameEditing
} from './ModelMetadata.js';
import { setupTagEditMode } from './ModelTags.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
@@ -466,7 +467,12 @@ export async function showModelModal(model, modelType) {
<div class="info-grid">
<div class="info-item">
<label>${translate('modals.model.metadata.version', {}, 'Version')}</label>
<span>${modelWithFullData.civitai?.name || 'N/A'}</span>
<div class="version-name-wrapper">
<span class="version-name-content">${modelWithFullData.civitai?.name || 'N/A'}</span>
<button class="edit-version-name-btn" title="${translate('modals.model.actions.editVersionName', {}, 'Edit version name')}">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div class="info-item">
<label>${translate('modals.model.metadata.fileName', {}, 'File Name')}</label>
@@ -660,6 +666,7 @@ export async function showModelModal(model, modelType) {
setupTagTooltip();
setupTagEditMode(modelType);
setupModelNameEditing(modelWithFullData.file_path);
setupVersionNameEditing(modelWithFullData.file_path);
setupBaseModelEditing(modelWithFullData.file_path);
setupFileNameEditing(modelWithFullData.file_path);
setupEventHandlers(modelWithFullData.file_path, modelType);

View File

@@ -274,7 +274,17 @@ async function saveTags() {
const filePath = editBtn.dataset.filePath;
const tagElements = document.querySelectorAll('.metadata-item');
const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
let tags = Array.from(tagElements).map(tag => tag.dataset.tag);
// Flush uncommitted input as a tag so it's not silently lost on save
const tagInput = document.querySelector('.metadata-input');
if (tagInput) {
const pendingTag = tagInput.value.trim().toLowerCase();
if (pendingTag && !tags.includes(pendingTag)) {
tags.push(pendingTag);
}
tagInput.value = '';
}
// Get original tags to compare
const originalTagElements = document.querySelectorAll('.tooltip-tag');
@@ -465,6 +475,7 @@ function setupTagInput() {
const tagInput = document.querySelector('.metadata-input');
if (tagInput) {
tagInput.focus();
tagInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();

View File

@@ -181,6 +181,13 @@ function isEarlyAccessActive(version) {
}
}
function isDownloadAllowed(version) {
if (!version.usageControl) {
return true;
}
return version.usageControl === 'Download';
}
function buildMetaMarkup(version, options = {}) {
const segments = [];
if (version.baseModel) {
@@ -230,16 +237,25 @@ function buildBadge(label, tone, options = {}) {
function buildActionButton(label, variant, action, options = {}) {
const attributes = [
`class="version-action ${variant}"`,
`data-version-action="${escapeHtml(action)}"`,
];
if (options.title) {
if (action) {
attributes.push(`data-version-action="${escapeHtml(action)}"`);
}
if (!options.disabled && options.title) {
attributes.push(`title="${escapeHtml(options.title)}"`);
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
}
if (options.disabled) {
attributes.push('disabled');
}
if (options.extraAttributes) {
attributes.push(options.extraAttributes);
}
return `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
const buttonHtml = `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
if (options.disabled && options.title) {
return `<span class="version-action-disabled-wrapper" title="${escapeHtml(options.title)}" aria-label="${escapeHtml(options.title)}">${buttonHtml}</span>`;
}
return buttonHtml;
}
const DISPLAY_FILTER_MODES = Object.freeze({
@@ -371,6 +387,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
if (hideEarlyAccess && isEarlyAccessActive(version)) {
return false;
}
if (!isDownloadAllowed(version)) {
return false;
}
const versionBase = normalizeBaseModelName(version.baseModel);
if (versionBase !== normalizedBase) {
return false;
@@ -502,6 +521,17 @@ function renderRow(version, options) {
}));
}
if (!isDownloadAllowed(version)) {
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', {
title: translate(
'modals.model.versions.badges.onSiteOnlyTooltip',
{},
'This version is only available for on-site generation on Civitai'
),
}));
}
if (version.shouldIgnore) {
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
title: translate(
@@ -524,25 +554,36 @@ function renderRow(version, options) {
const actions = [];
if (!version.isInLibrary) {
// Download button with optional EA bolt icon
const canDownload = isDownloadAllowed(version);
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
let downloadTitle;
if (!canDownload) {
downloadTitle = translate(
'modals.model.versions.actions.downloadNotAllowedTooltip',
{},
'This version is only available for on-site generation on Civitai'
);
} else if (isEarlyAccess) {
downloadTitle = translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip',
{},
'Download this early access version from Civitai'
);
} else {
downloadTitle = translate(
'modals.model.versions.actions.downloadTooltip',
{},
'Download this version'
);
}
actions.push(buildActionButton(
downloadLabel,
'version-action-primary',
'download',
canDownload ? 'version-action-primary' : 'version-action-disabled',
canDownload ? 'download' : '',
{
title: isEarlyAccess
? translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip',
{},
'Download this early access version from Civitai'
)
: translate(
'modals.model.versions.actions.downloadTooltip',
{},
'Download this version'
),
title: downloadTitle,
iconMarkup: downloadIcon,
disabled: !canDownload,
}
));
} else if (version.filePath) {

View File

@@ -17,6 +17,7 @@ import {
import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
import { openMediaViewer } from '../MediaViewer.js';
export const showcaseListenerMetrics = {
wheelListeners: 0,
@@ -640,6 +641,27 @@ export function initShowcaseContent(carousel) {
initMediaControlHandlers(carousel);
positionAllMediaControls(carousel);
// Click-to-view: open full-size media viewer when clicking showcase images/videos
const viewerElements = carousel.querySelectorAll('.media-wrapper img, .media-wrapper video');
const allItems = [];
const elementIndexMap = new Map();
viewerElements.forEach((el) => {
const isVideo = el.tagName === 'VIDEO';
const url = el.src || el.dataset.localSrc || el.dataset.remoteSrc;
if (url) {
elementIndexMap.set(el, allItems.length);
allItems.push({ url, type: isVideo ? 'video' : 'image' });
}
});
viewerElements.forEach((mediaEl) => {
const idx = elementIndexMap.get(mediaEl);
if (idx === undefined) return;
mediaEl.addEventListener('click', (e) => {
e.stopPropagation();
openMediaViewer(allItems, idx);
});
});
// Bind scroll-indicator click events
bindScrollIndicatorEvents(carousel);

View File

@@ -3,7 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
@@ -41,7 +41,9 @@ export class BulkManager {
autoOrganize: true,
deleteAll: true,
setContentRating: true,
skipMetadataRefresh: true
skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
},
[MODEL_TYPES.EMBEDDING]: {
addTags: true,
@@ -53,7 +55,9 @@ export class BulkManager {
autoOrganize: true,
deleteAll: true,
setContentRating: false,
skipMetadataRefresh: true
skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
},
[MODEL_TYPES.CHECKPOINT]: {
addTags: true,
@@ -65,10 +69,12 @@ export class BulkManager {
autoOrganize: true,
deleteAll: true,
setContentRating: true,
skipMetadataRefresh: true
skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
},
recipes: {
addTags: false,
addTags: true,
sendToWorkflow: false,
copyAll: false,
refreshAll: false,
@@ -77,7 +83,10 @@ export class BulkManager {
autoOrganize: false,
deleteAll: true,
setContentRating: false,
skipMetadataRefresh: false
skipMetadataRefresh: false,
setFavorite: true,
unfavorite: true,
repairMetadata: true
}
};
@@ -538,9 +547,23 @@ export class BulkManager {
return;
}
const countElement = document.getElementById('bulkDeleteCount');
if (countElement) {
countElement.textContent = state.selectedModels.size;
const count = state.selectedModels.size;
const isRecipes = state.currentPageType === 'recipes';
const keyPrefix = isRecipes ? 'modals.bulkDeleteRecipes' : 'modals.bulkDelete';
const titleEl = document.querySelector('#bulkDeleteModal h2');
if (titleEl) {
titleEl.textContent = translate(`${keyPrefix}.title`);
}
const messageEl = document.querySelector('#bulkDeleteModal .delete-message');
if (messageEl) {
messageEl.textContent = translate(`${keyPrefix}.message`);
}
const countInfoEl = document.querySelector('#bulkDeleteModal .delete-model-info p');
if (countInfoEl) {
countInfoEl.innerHTML = `<span id="bulkDeleteCount">${count}</span> ${translate(`${keyPrefix}.countMessage`)}`;
}
modalManager.showModal('bulkDeleteModal');
@@ -634,6 +657,76 @@ export class BulkManager {
}
}
async repairSelectedRecipes() {
if (state.selectedModels.size === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
return;
}
if (state.currentPageType !== 'recipes') {
showToast('This operation is only available for recipes', {}, 'warning');
return;
}
try {
const apiClient = this.getActiveApiClient();
const filePaths = Array.from(state.selectedModels);
if (typeof apiClient.repairBulkModels !== 'function') {
showToast('Bulk repair is not supported for this model type', {}, 'error');
return;
}
state.loadingManager.showSimpleLoading('Repairing recipe metadata...');
const result = await apiClient.repairBulkModels(filePaths);
if (result.success) {
const total = result.total || filePaths.length;
const repaired = result.repaired || 0;
const skipped = result.skipped || 0;
const recipes = result.recipes || [];
for (const recipe of recipes) {
if (recipe.file_path) {
state.virtualScroller.updateSingleItem(
recipe.file_path,
recipe
);
}
}
if (repaired > 0) {
showToast(
'toast.recipes.repairBulkComplete',
{ repaired, skipped, total },
'success'
);
} else {
showToast(
'toast.recipes.repairBulkSkipped',
{ total },
'info'
);
}
this.clearSelection();
} else {
throw new Error(result.error || 'Bulk repair failed');
}
} catch (error) {
console.error('Error during bulk recipe repair:', error);
showToast('toast.recipes.repairBulkFailed', { message: error.message }, 'error');
} finally {
if (state.loadingManager?.hide) {
state.loadingManager.hide();
}
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
state.loadingManager.restoreProgressBar();
}
}
}
async refreshAllMetadata() {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
@@ -763,6 +856,7 @@ export class BulkManager {
// Setup tag input behavior
const tagInput = document.querySelector('.bulk-metadata-input');
if (tagInput) {
tagInput.focus();
tagInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
@@ -986,7 +1080,17 @@ export class BulkManager {
async saveBulkTags(mode = 'append') {
const tagElements = document.querySelectorAll('#bulkTagsItems .metadata-item');
const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
let tags = Array.from(tagElements).map(tag => tag.dataset.tag);
// Flush uncommitted input as a tag so it's not silently lost on save
const tagInput = document.querySelector('.bulk-metadata-input');
if (tagInput) {
const pendingTag = tagInput.value.trim().toLowerCase();
if (pendingTag && !tags.includes(pendingTag)) {
tags.push(pendingTag);
}
tagInput.value = '';
}
if (tags.length === 0) {
showToast('toast.models.noTagsToAdd', {}, 'warning');
@@ -1010,6 +1114,8 @@ export class BulkManager {
cancelled = true;
});
const isRecipes = state.currentPageType === 'recipes';
// Add or replace tags for each selected model based on mode
for (const filePath of filePaths) {
if (cancelled) {
@@ -1017,7 +1123,9 @@ export class BulkManager {
break;
}
try {
if (mode === 'replace') {
if (isRecipes) {
await this._saveRecipeTags(filePath, tags, mode);
} else if (mode === 'replace') {
await apiClient.saveModelMetadata(filePath, { tags: tags });
} else {
await apiClient.addTags(filePath, { tags: tags });
@@ -1056,6 +1164,35 @@ export class BulkManager {
}
}
async _saveRecipeTags(filePath, newTags, mode) {
const recipeId = extractRecipeId(filePath);
if (!recipeId) throw new Error('Unable to determine recipe ID');
let finalTags = newTags;
if (mode === 'append') {
const recipeItem = state.virtualScroller?.items?.find(
item => item.file_path === filePath
);
const existingTags = recipeItem?.tags || [];
finalTags = [...new Set([...existingTags, ...newTags])];
}
const response = await fetch(
`/api/lm/recipe/${encodeURIComponent(recipeId)}/update`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags: finalTags }),
}
);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to update recipe tags');
}
state.virtualScroller.updateSingleItem(filePath, { tags: finalTags });
}
cleanupBulkAddTagsModal() {
// Clear tags container
const tagsContainer = document.getElementById('bulkTagsItems');
@@ -1090,6 +1227,60 @@ export class BulkManager {
}
}
async setBulkFavorites(value) {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
const totalCount = state.selectedModels.size;
const isRecipesPage = state.currentPageType === 'recipes';
state.loadingManager.showSimpleLoading(
translate(value ? 'toast.models.bulkFavoriteUpdating' : 'toast.models.bulkUnfavoriteUpdating', { count: totalCount })
);
let cancelled = false;
state.loadingManager.showCancelButton(() => {
cancelled = true;
});
let successCount = 0;
let failureCount = 0;
try {
for (const filePath of state.selectedModels) {
if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
break;
}
try {
if (isRecipesPage) {
await updateRecipeMetadata(filePath, { favorite: value });
} else {
const apiClient = getModelApiClient();
await apiClient.saveModelMetadata(filePath, { favorite: value });
}
successCount++;
} catch (error) {
failureCount++;
console.error(`Failed to set favorite=${value} for ${filePath}:`, error);
}
}
} finally {
state.loadingManager?.hide?.();
}
if (successCount === totalCount) {
const toastKey = value ? 'modelCard.favorites.added' : 'modelCard.favorites.removed';
showToast(toastKey, {}, 'success');
} else if (successCount > 0) {
const toastKey = value ? 'toast.models.bulkFavoritePartialAdded' : 'toast.models.bulkFavoritePartialRemoved';
showToast(toastKey, { success: successCount, failed: failureCount }, 'warning');
} else {
showToast('toast.models.bulkFavoriteFailed', {}, 'error');
}
}
/**
* Show bulk base model modal
*/

View File

@@ -2,6 +2,7 @@ import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { escapeHtml } from '../components/shared/utils.js';
import { state } from '../state/index.js';
const MAX_CONSOLE_ENTRIES = 200;
@@ -224,6 +225,13 @@ export class DoctorManager {
renderIssueCard(item) {
const status = item.status || 'ok';
const tagLabel = this.getStatusLabel(status);
const titleKey = `doctor.issues.${item.id || ''}.title`;
const displayTitle = translate(titleKey, {}, item.title || '');
const summaryKey = `doctor.issues.${item.id || ''}.summary.${status}`;
const displaySummary = translate(summaryKey, {}, item.summary || '');
const details = Array.isArray(item.details) ? item.details : [];
const listItems = details
.filter((detail) => typeof detail === 'string')
@@ -234,19 +242,22 @@ export class DoctorManager {
.map((detail) => this.renderInlineDetail(detail))
.join('');
const actions = (item.actions || [])
.map((action) => `
.map((action) => {
const actionLabel = translate(`doctor.actions.${action.id}`, {}, action.label);
return `
<button class="${action.id === 'repair-cache' || action.id === 'reload-page' ? 'primary-btn' : 'secondary-btn'}" data-doctor-action="${escapeHtml(action.id)}">
${escapeHtml(action.label)}
${escapeHtml(actionLabel)}
</button>
`)
`;
})
.join('');
return `
<section class="doctor-issue-card" data-status="${escapeHtml(status)}" data-issue-id="${escapeHtml(item.id || '')}">
<div class="doctor-issue-header">
<div>
<h3>${escapeHtml(item.title || '')}</h3>
<p class="doctor-issue-summary">${escapeHtml(item.summary || '')}</p>
<h3>${escapeHtml(displayTitle)}</h3>
<p class="doctor-issue-summary">${escapeHtml(displaySummary)}</p>
</div>
<span class="doctor-issue-tag">${escapeHtml(tagLabel)}</span>
</div>
@@ -258,6 +269,15 @@ export class DoctorManager {
}
renderInlineDetail(detail) {
if (detail.conflict_groups || detail.total_conflict_files) {
return `
<div class="doctor-inline-detail">
<strong>${escapeHtml(translate('doctor.labels.conflicts', {}, 'Conflicts'))}</strong>
<div>${escapeHtml(`${detail.conflict_groups || 0} filenames, ${detail.total_conflict_files || 0} files`)}</div>
</div>
`;
}
if (detail.client_version || detail.server_version) {
return `
<div class="doctor-inline-detail">
@@ -314,9 +334,43 @@ export class DoctorManager {
}
}, 100);
break;
case 'open-settings-syntax-format':
modalManager.showModal('settingsModal');
window.setTimeout(() => {
// Switch to Interface section
document.querySelectorAll('.settings-section').forEach((s) => s.classList.remove('active'));
const interfaceSection = document.getElementById('section-interface');
if (interfaceSection) {
interfaceSection.classList.add('active');
}
document.querySelectorAll('.settings-nav-item').forEach((n) => n.classList.remove('active'));
const interfaceNav = document.querySelector('.settings-nav-item[data-section="interface"]');
if (interfaceNav) {
interfaceNav.classList.add('active');
}
// Focus and scroll to the LoRA Syntax Format dropdown
const select = document.getElementById('loraSyntaxFormat');
if (select) {
select.focus();
select.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Add temporary highlight animation
const settingItem = select.closest('.setting-item');
if (settingItem) {
settingItem.classList.add('settings-setting-highlight');
setTimeout(() => {
settingItem.classList.remove('settings-setting-highlight');
}, 4500);
}
}
}, 100);
break;
case 'repair-cache':
await this.repairCache();
break;
case 'resolve-filename-conflicts':
await this.promptResolveConflicts();
break;
case 'reload-page':
this.reloadUi();
break;
@@ -345,6 +399,103 @@ export class DoctorManager {
}
}
_getConflictStats() {
const conflict = (this.lastDiagnostics?.diagnostics || []).find(
(d) => d.id === 'filename_conflicts'
);
if (!conflict || !Array.isArray(conflict.details)) {
return { groups: 0, files: 0 };
}
const summary = conflict.details.find(
(d) => d && typeof d === 'object' && d.conflict_groups !== undefined
);
return {
groups: summary?.conflict_groups || 0,
files: summary?.total_conflict_files || 0,
};
}
async promptResolveConflicts() {
const stats = this._getConflictStats();
if (stats.groups === 0) {
return;
}
const detailEl = document.getElementById('resolveConflictsDetail');
if (detailEl) {
detailEl.innerHTML = translate(
'conflictConfirm.detail',
{},
'Example: <code>Add_Details_v1.2</code> \u2192 <code>Add_Details_v1.2-a3f7</code>'
);
}
const impactEl = document.getElementById('resolveConflictsImpact');
if (impactEl) {
impactEl.innerHTML = translate(
'conflictConfirm.impact',
{ count: stats.files, groups: stats.groups },
`Will rename <strong>${stats.files}</strong> file(s) across <strong>${stats.groups}</strong> duplicate group(s).`
);
}
this._confirmResolveResolve = null;
modalManager.showModal('resolveFilenameConflictsModal');
return new Promise((resolve) => {
this._confirmResolveResolve = resolve;
});
}
async confirmResolveConflicts() {
modalManager.closeModal('resolveFilenameConflictsModal');
if (this._confirmResolveResolve) {
this._confirmResolveResolve(true);
this._confirmResolveResolve = null;
}
await this.resolveFilenameConflicts();
}
async resolveFilenameConflicts() {
try {
this.setLoading(true);
const response = await fetch('/api/lm/doctor/resolve-filename-conflicts', { method: 'POST' });
const payload = await response.json();
if (!response.ok || payload.success === false) {
throw new Error(payload.error || 'Failed to resolve filename conflicts.');
}
const renamedCount = payload.count || 0;
showToast(
'doctor.toast.conflictsResolved',
{ count: renamedCount },
'success'
);
// Update scroller items so model cards reflect new filenames immediately
if (state.virtualScroller && payload.renamed) {
for (const renamed of payload.renamed) {
const baseName = renamed.new_filename.replace(/\.[^.]+$/, '');
state.virtualScroller.updateSingleItem(renamed.old_path, {
file_name: baseName,
file_path: renamed.new_path,
});
}
}
await this.refreshDiagnostics({ silent: true });
} catch (error) {
console.error('Doctor filename conflict resolution failed:', error);
showToast(
'doctor.toast.conflictsResolveFailed',
{ message: error.message },
'error'
);
} finally {
this.setLoading(false);
}
}
async exportBundle() {
try {
this.setLoading(true);
@@ -395,3 +546,8 @@ export class DoctorManager {
}
export const doctorManager = new DoctorManager();
// Make available globally for HTML onclick handlers
if (typeof window !== 'undefined') {
window.doctorManager = doctorManager;
}

View File

@@ -70,6 +70,9 @@ export class FilterManager {
// Initialize tag logic toggle
this.initializeTagLogicToggle();
// Create auto-tag filter section (I2V, T2V, TI2V, Lightning, Turbo)
this.createAutoTagFilters();
// Add click handler for filter button
if (this.filterButton) {
this.filterButton.addEventListener('click', () => {
@@ -480,6 +483,58 @@ export class FilterManager {
}
}
AUTO_TAG_FILTER_TAGS = ['I2V', 'T2V', 'TI2V', 'Lightning', 'Turbo'];
createAutoTagFilters() {
const container = document.getElementById('autoTagFilterTags');
if (container) return;
const modelTypeSection = document.getElementById('modelTypeTags')?.closest('.filter-section');
if (!modelTypeSection) return;
const section = document.createElement('div');
section.className = 'filter-section';
section.innerHTML = `
<h4>${translate('header.filter.autoTags', {}, 'Auto Tags')}</h4>
<div class="filter-tags" id="autoTagFilterTags"></div>
`;
modelTypeSection.parentNode.insertBefore(section, modelTypeSection.nextSibling);
const tagsContainer = document.getElementById('autoTagFilterTags');
this.AUTO_TAG_FILTER_TAGS.forEach(tag => {
const el = document.createElement('div');
el.className = 'filter-tag auto-tag-filter';
el.dataset.autoTag = tag;
el.textContent = tag;
// Restore previous state
const state = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
this._applyTriState(el, state);
el.addEventListener('click', async () => {
const current = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
const next = current === 'none' ? 'include' : current === 'include' ? 'exclude' : 'none';
if (!this.filters.autoTags) this.filters.autoTags = {};
if (next === 'none') {
delete this.filters.autoTags[tag];
} else {
this.filters.autoTags[tag] = next;
}
this._applyTriState(el, next);
this.updateActiveFiltersCount();
await this.applyFilters(false);
});
tagsContainer.appendChild(el);
});
}
_applyTriState(el, state) {
el.classList.remove('active', 'exclude');
if (state === 'include') el.classList.add('active');
else if (state === 'exclude') el.classList.add('exclude');
}
toggleFilterPanel() {
if (this.filterPanel) {
const isHidden = this.filterPanel.classList.contains('hidden');
@@ -540,6 +595,13 @@ export class FilterManager {
this.updateLicenseSelections();
}
this.updateModelTypeSelections();
const autoTagEls = document.querySelectorAll('.auto-tag-filter');
autoTagEls.forEach(el => {
const tag = el.dataset.autoTag;
const state = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
this._applyTriState(el, state);
});
}
updateModelTypeSelections() {
@@ -556,11 +618,12 @@ export class FilterManager {
updateActiveFiltersCount() {
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
const autoTagFilterCount = this.filters.autoTags ? Object.keys(this.filters.autoTags).length : 0;
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
const modelTypeFilterCount = this.filters.modelTypes.length;
// Exclude EMPTY_WILDCARD_MARKER from base model count
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
const totalActiveFilters = baseModelCount + tagFilterCount + licenseFilterCount + modelTypeFilterCount;
const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount;
if (this.activeFiltersCount) {
if (totalActiveFilters > 0) {
@@ -599,7 +662,7 @@ export class FilterManager {
// Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true);
await window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
// For models page, reset the page and reload
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
@@ -652,6 +715,7 @@ export class FilterManager {
...this.filters,
baseModel: [],
tags: {},
autoTags: {},
license: {},
modelTypes: [],
tagLogic: 'any'
@@ -682,7 +746,7 @@ export class FilterManager {
// Reload data using the appropriate method for the current page
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true);
await window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
}
@@ -721,6 +785,7 @@ export class FilterManager {
hasActiveFilters() {
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
const autoTagCount = this.filters.autoTags ? Object.keys(this.filters.autoTags).length : 0;
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
const modelTypeCount = this.filters.modelTypes.length;
// Exclude EMPTY_WILDCARD_MARKER from base model count
@@ -728,6 +793,7 @@ export class FilterManager {
return (
baseModelCount > 0 ||
tagCount > 0 ||
autoTagCount > 0 ||
licenseCount > 0 ||
modelTypeCount > 0
);
@@ -739,6 +805,7 @@ export class FilterManager {
...source,
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
tags: this.normalizeTagFilters(source.tags),
autoTags: this.normalizeTagFilters(source.autoTags),
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
tagLogic: source.tagLogic || 'any'
@@ -822,6 +889,7 @@ export class FilterManager {
...this.filters,
baseModel: [...(this.filters.baseModel || [])],
tags: { ...(this.filters.tags || {}) },
autoTags: { ...(this.filters.autoTags || {}) },
license: { ...(this.filters.license || {}) },
modelTypes: [...(this.filters.modelTypes || [])],
tagLogic: this.filters.tagLogic || 'any'

View File

@@ -316,6 +316,19 @@ export class ModalManager {
});
}
// Register resolveFilenameConflictsModal
const resolveFilenameConflictsModal = document.getElementById('resolveFilenameConflictsModal');
if (resolveFilenameConflictsModal) {
this.registerModal('resolveFilenameConflictsModal', {
element: resolveFilenameConflictsModal,
onClose: () => {
this.getModal('resolveFilenameConflictsModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true;
}
@@ -396,7 +409,8 @@ export class ModalManager {
id === "modelDuplicateDeleteModal" ||
id === "clearCacheModal" ||
id === "bulkDeleteModal" ||
id === "checkUpdatesConfirmModal"
id === "checkUpdatesConfirmModal" ||
id === "resolveFilenameConflictsModal"
) {
modal.element.classList.add("show");
} else {

View File

@@ -301,7 +301,7 @@ export class SearchManager {
// Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) {
window.recipeManager.loadRecipes(true); // true to reset pagination
window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
// For models page, reset the page and reload
getModelApiClient().loadMoreWithVirtualScroll(true, false);

View File

@@ -295,6 +295,13 @@ export class SettingsManager {
// Update state
state.global.settings[settingKey] = value;
if (settingKey === 'lora_syntax_format') {
try {
localStorage.setItem('lm:lora-syntax-format-changed', Date.now().toString());
} catch (_) {
}
}
if (!this.isBackendSetting(settingKey)) {
return;
}
@@ -879,6 +886,12 @@ export class SettingsManager {
modelCardFooterActionSelect.value = state.global.settings.model_card_footer_action || 'example_images';
}
// Set show version on card
const showVersionOnCardCheckbox = document.getElementById('showVersionOnCard');
if (showVersionOnCardCheckbox) {
showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false;
}
// Set model name display setting
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
if (modelNameDisplaySelect) {
@@ -943,6 +956,12 @@ export class SettingsManager {
includeTriggerWordsCheckbox.checked = state.global.settings.include_trigger_words || false;
}
// Set lora syntax format
const loraSyntaxFormatSelect = document.getElementById('loraSyntaxFormat');
if (loraSyntaxFormatSelect) {
loraSyntaxFormatSelect.value = state.global.settings.lora_syntax_format || 'legacy';
}
// Load metadata archive settings
await this.loadMetadataArchiveSettings();
@@ -2857,7 +2876,7 @@ export class SettingsManager {
await resetAndReload(false);
} else if (this.currentPage === 'recipes') {
// Reload the recipes without updating folders
await window.recipeManager.loadRecipes();
await window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'checkpoints') {
// Reload the checkpoints without updating folders
await resetAndReload(false);
@@ -2890,6 +2909,10 @@ export class SettingsManager {
const cardInfoDisplay = state.global.settings.card_info_display || 'always';
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
// Apply show version on card setting
const showVersionOnCard = state.global.settings.show_version_on_card !== false;
document.body.classList.toggle('hide-card-version', !showVersionOnCard);
const shouldShowSidebar = state.global.settings.show_folder_sidebar !== false;
if (sidebarManager && typeof sidebarManager.setSidebarEnabled === 'function') {
sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {

View File

@@ -731,9 +731,16 @@ export class UpdateService {
}
// Simple markdown parser for changelog items
// Simple markdown parser for changelog items
// Escape HTML entities first so angle brackets in content (e.g. `<lora:x>`)
// aren't swallowed by innerHTML's HTML parser as invalid tags
parseMarkdown(text) {
if (!text) return '';
text = text.replace(/&/g, '&amp;');
text = text.replace(/</g, '&lt;');
text = text.replace(/>/g, '&gt;');
// Handle bold text (**text**)
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');

View File

@@ -19,7 +19,7 @@ class RecipePageControls {
}
async resetAndReload() {
refreshVirtualScroll();
await refreshVirtualScroll({ preserveScroll: true });
}
async refreshModels(fullRebuild = false) {
@@ -286,16 +286,6 @@ class RecipeManager {
});
});
// Handle quick refresh option (Sync Changes)
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
if (quickRefreshOption) {
quickRefreshOption.addEventListener('click', (e) => {
e.stopPropagation();
this.pageControls.refreshModels(false);
this.closeDropdowns();
});
}
// Handle full rebuild option (Rebuild Cache)
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
if (fullRebuildOption) {
@@ -407,4 +397,4 @@ document.addEventListener('DOMContentLoaded', async () => {
});
// Export for use in other modules
export { RecipeManager };
export { RecipeManager };

View File

@@ -37,7 +37,9 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
card_info_display: 'always',
show_folder_sidebar: true,
model_name_display: 'model_name',
lora_syntax_format: 'legacy',
model_card_footer_action: 'example_images',
show_version_on_card: true,
include_trigger_words: false,
compact_mode: false,
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
@@ -49,6 +51,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
download_skip_base_models: [],
backup_auto_enabled: true,
backup_retention_count: 5,
strip_lora_on_copy: false,
});
export function createDefaultSettings() {

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