Compare commits

..

133 Commits

Author SHA1 Message Date
Will Miao
237a015cde chore(release): bump version to v1.1.0 2026-06-12 23:38:16 +08:00
Will Miao
1ae2778baa feat(sidebar): add per-page hide toggle with more options dropdown
- Add ``` button in sidebar header with dropdown menu
- Add "Hide sidebar on this page" option with per-page localStorage state
- Show edge indicator (14px chevron) on left when hidden per-page
- Show brief toast notification when hiding
- Fix container margin not resetting when sidebar is per-page hidden
- Add i18n translations for all 10 locales
2026-06-12 18:27:54 +08:00
Will Miao
84fcdb5f20 fix(recipe): compute folder field on save to prevent reimported recipes disappearing from subfolder grid 2026-06-12 16:49:57 +08:00
Will Miao
8a0b368b44 feat(downloads): add persistent download queue/history with REST API 2026-06-12 15:00:21 +08:00
Will Miao
3990535505 fix(i18n): align bulk reimport label with single context menu, drop 'Metadata' for clarity 2026-06-12 10:19:33 +08:00
Will Miao
3e961a9860 fix(stats): load embeddings from saved stats on startup
_load_stats() was missing the embeddings section, so on every restart
the embeddings usage tracking hash would start from an empty dict.
This caused all previously saved embedding usage data to appear reset.

Added the missing load path for the 'embeddings' key, parallel to the
existing checkpoints and loras loading logic.
2026-06-12 08:57:25 +08:00
Will Miao
d6669f1d04 fix(ui): stabilize node selector ordering by type then ID 2026-06-12 08:47:11 +08:00
Will Miao
519bafebc8 fix(i18n): add missing embedding translation keys, sync locales, clean up dead replaceMode branch 2026-06-11 23:03:14 +08:00
Will Miao
d87863b423 feat(embedding): send embedding to workflow + fix copy button format
- Fix copy button on embedding cards to copy 'embedding:folder/name' format
- Add send-embedding-to-workflow for Prompt (LoraManager), Text (LoraManager),
  and CLIPTextEncode nodes, appending embedding code to text content
- Extend workflow registry to register text-capable nodes by comfyClass
  (not generic widget name 'text') to avoid false matches
- Add mode parameter to update_node_widget API/event for append support
- Fix single/bulk context menus: single shows plain 'Send to Workflow',
  bulk collapses submenu into direct action for embeddings (append-only)
2026-06-11 22:41:42 +08:00
Will Miao
84e9fe2dfb fix(import): defer git import to module-level to prevent startup crash when git executable missing (#971) 2026-06-11 21:47:55 +08:00
Will Miao
46cbcf94c8 fix(recipe): reimport data loss, local file support, and scroll bugs
- Add local file reimport support via _do_reimport_from_local
- Validate source_path BEFORE deleting old recipe (prevent data loss)
- Move delete_recipe after save_recipe (safe ordering)
- Preserve folder location, NSFW level, and carry over user edits
- Remove old timestamp preservation (use current time)
- Add scrollTop reset in resetAndReloadWithVirtualScroll
- Only reload on successful bulk reimport (avoid empty grid)
- Disable preserveScroll for both single and bulk reimport
2026-06-11 21:31:30 +08:00
Will Miao
05f3018495 refactor(stats): move lora_manager_stats.json from loras root to settings_dir/stats/
- Change _get_stats_file_path() to use get_settings_dir()/stats/ instead of
  first loras root directory
- Add _migrate_from_old_location() to copy existing stats from loras root
  to new location on first access, then clean up old file
- Add 'stats' to update protection skip lists (clean, extract, tracking)
  to prevent data loss during ZIP/git upgrades in portable mode
- Add usage_stats entry to backup targets and restore resolver so stats
  are included in automatic snapshots
2026-06-11 18:03:29 +08:00
Will Miao
f565cc35ca feat(stats): track embedding usage from prompt text — Plan A + hybrid approach docs 2026-06-11 17:12:34 +08:00
Will Miao
dd1cdce16d fix(ui): unify context menu ordering and add visual section separators across all menus 2026-06-10 22:18:43 +08:00
Will Miao
a9e0e7dc8d feat(recipe): add reimport UI with context menus, progress display, and i18n
- Single recipe right-click menu: Re-import from Source
- Bulk context menu: Re-import Metadata for Selected
- Progress overlay with LoadingManager for single and bulk operations
- Virtual scroller data lookup (replaces fragile DOM querySelector)
- Fix dynamic import path for resetAndReload on recipe pages
- Add translation keys for all 9 supported languages
2026-06-10 21:51:04 +08:00
Will Miao
b302d1db7d feat(recipe): add reimport endpoint to re-import recipe from source URL
Adds POST /api/lm/recipe/{recipe_id}/reimport that atomically:
1. Reads the existing recipe to extract source_url and user edits
2. Deletes the old recipe files and cache entries
3. Re-downloads the image from CivitAI, re-parses EXIF metadata
4. Carries over user edits (title, tags, favorite) and timestamps
2026-06-10 21:50:43 +08:00
Will Miao
7cbddd9cf7 fix(recipe): fall back to original image for metadata extraction when optimized lacks embedded data (#968)
When CivitAI API returns meta=null and the optimized CDN image has no
embedded generation parameters (e.g. PNG tEXt chunks stripped by
Cloudflare Images), download the original image as fallback to recover
full recipe metadata (prompt, seed, LoRAs, etc.).

Also fixes Chrome password manager popping up on recipe save by adding
autocomplete="new-password" to the settings API key and proxy password
fields.
2026-06-10 15:06:56 +08:00
Will Miao
cb8c699224 chore(template): update template workflow 2026-06-10 15:01:48 +08:00
Will Miao
451f74b874 fix(ui): return minWidth/minHeight from autocomplete text widget factory for proper node initial sizing 2026-06-09 15:21:45 +08:00
pixelpaws
a1d248baa6 Merge pull request #966 from willmiao/design-token-system-phase4
Design token system phase4
2026-06-09 14:37:02 +08:00
Will Miao
18577fa336 refactor(phase-4): standardize remaining transitions and box-shadows
- Replace all remaining 'transition: all' with specific token-based transitions
- Replace 80+ hardcoded box-shadow rgba values with semantic tokens
- Add new tokens: --shadow-side, --shadow-elevated, --shadow-dialog, --shadow-inset-top
- Update dark theme overrides for new shadow tokens
- 32 files changed, net +8 lines (more consistent, less duplication)
2026-06-09 14:27:53 +08:00
Will Miao
5797ce9408 feat(phase-4): visual polish — font stack, shadow system, transitions, micro-interactions
Phase 4: Visual Polish

4.1 Font Stack Upgrade:
- Add --font-display token for headings
- Replace all hardcoded font-family: monospace with var(--font-mono)
- Replace hardcoded 'Segoe UI' stack with var(--font-body)

4.2 Shadow Elevation System:
- Add --shadow-2xl, --shadow-card/dropdown/modal/toast/header/dark-lg tokens
- Replace hardcoded shadows in header, menu, banner, shared, recipe-modal,
  progress-panel, import-modal, alphabet-bar with semantic tokens
- Add dark theme shadow overrides with increased opacity

4.3 Transitions & Micro-interactions:
- Replace transition: all with specified properties (performance)
- Use --transition-fast/base/slow tokens instead of hardcoded 0.2s/0.3s
- Add :active scale feedback to modal buttons
- Enhance card hover with box-shadow + border-color lift

4.4 Dark Theme Refinement:
- Elevated shadow opacity for dark theme visibility

4.5 Density:
- Standardize container padding with --space-2 token

21 files changed
2026-06-09 14:07:36 +08:00
pixelpaws
826f06255a Merge pull request #964 from willmiao/design-token-system
Design token system phase1
2026-06-09 11:38:31 +08:00
Will Miao
84e16b5c5b refactor(css): remove hardcoded background/border from modal sections - use design tokens instead 2026-06-09 09:52:11 +08:00
Will Miao
eb22054580 fix: add --surface-subtle token, restore info grouping, and apply theme-aware favorite color
- Add --surface-subtle (oklch 3% opacity) to replace rgba(0,0,0,0.03)
- Fix info items, creator-info, civitai-view, modal-send-btn, header-actions
  to use --surface-subtle instead of --surface-hover
- Keep true hover states on --surface-hover
- Use light #d4a017 / dark #ffc107 for --favorite-color based on theme
- Replace hardcoded #ffc107 and #d4a017 with var(--favorite-color)
2026-06-09 09:27:11 +08:00
Will Miao
08afb05ece refactor: normalize components in Phase 2
- Unify button styles (padding, gap, border-radius, hover states) in _base.css
- Fix .secondary-btn syntax error (extra space in var())
- Remove duplicated .card-actions in card.css
- Replace hardcoded #f0f0f0 with --surface-hover token
- Replace #ffc107 with accessible #d4a017 for favorite stars
- Replace hardcoded rgba shadows with semantic --shadow-* tokens in layout.css
- Replace hardcoded rgba(0,0,0,0.03)/rgba(255,255,255,0.03) with --surface-hover
- Remove redundant [data-theme=dark] overrides by using theme-aware tokens
- Replace .dropdown-main hardcoded border with --border-color token
2026-06-09 09:26:28 +08:00
Will Miao
f51f125cf1 feat: introduce design token system foundation
- Add semantic OKLch color tokens with light/dark themes
- Add typography, spacing, effects, breakpoints, z-index tokens
- Refactor base.css with backward-compatible aliases
- Add prefers-reduced-motion support
- Add MIGRATION.md for Phase 2 component audit
2026-06-09 09:26:28 +08:00
Will Miao
24b2078f21 fix: batch URL download UI polish - hint text, label, and i18n (#936)
- Add .input-hint helper text below textarea guiding multi-URL input
- Update label to CivitAI URL(s): for batch-agnostic hint
- Add urlHint locale key across all 10 languages
- Remove unused url locale key
2026-06-09 07:57:33 +08:00
Will Miao
130fb5d2d5 fix: batch URL download dedup by modelId+modelVersionId composite key (#936)
When batch-downloading different versions of the same model, dedup by
modelId alone discards the second URL. Use modelId:modelVersionId as
the dedup key so users can download, e.g., latest + a specific version.
2026-06-09 07:02:56 +08:00
Will Miao
23c6863a3a fix: batch URL download i18n and CSS polish (#936)
- Add common.actions.remove/change translation keys across all locales
- Remove hardcoded #e74c3c error colors, use --lora-error CSS variable
2026-06-08 21:28:24 +08:00
Will Miao
c0e2578640 feat(ui): add adaptive expand/collapse for Additional Notes section (#962) 2026-06-08 20:52:41 +08:00
Will Miao
e3c812367e fix(ui): cap lora widget height and enable wheel scroll in Node 2.0 mode (#959)
- Add 'Node 2.0: Maximum visible LoRA entries' setting (default 12)
- Apply max-height to loras container in Vue mode to prevent unbounded growth
- Add enableListWheelScroll: window capture-phase wheel hook so scroll
  inside the widget scrolls the list instead of zooming the canvas
2026-06-08 16:19:08 +08:00
Will Miao
4d239008a6 fix(update): respect hide_early_access_updates in refresh toast count
The refresh_model_updates handler was calling record.has_update() with
default hide_early_access=False, causing the toast to report early-access
updates that the Updates filter (which uses the user's hide_early_access
setting) would then hide. This resulted in misleading "Found N updates"
toasts followed by an empty Updates view.

Now the handler reads hide_early_access_updates from settings and passes
it to has_update(), matching the behavior of _serialize_record and
_annotate_update_flags.
2026-06-08 13:58:21 +08:00
Will Miao
00177a06d0 fix(ui): keep autocomplete text widget at max-height on node resize in Vue mode 2026-06-08 10:49:04 +08:00
Will Miao
568daa351e Revert "Merge pull request #959 from id-fa/fix/lora-loader-list-scroll-nodes2"
This reverts commit 01dac57c35, reversing
changes made to 62f9e3f44a.
2026-06-07 17:25:30 +08:00
Will Miao
5a4664fa12 Merge pull request #936 from 1756141021/feat/batch-url-download
feat: batch URL download for LoRA models
2026-06-06 20:22:52 +08:00
Will Miao
dd5b213adc fix(ui): make autocomplete text widget scrollable in Nodes 2.0 mode
In Vue/Node 2.0 mode, the AutocompleteTextWidget's textarea wheel events were intercepted by TransformPane @wheel.capture before reaching the @wheel handler, causing canvas zoom instead of text scrolling.

- Add lm-wheel-scrollable class in Vue mode to hook into the window capture-phase handler (enableListWheelScroll) which scrolls the textarea manually before TransformPane can react.
- Add maxHeight prop and container max-height for Lora Loader/Stacker/WanVideo nodes (modelType === 'loras'), matching canvas mode's height cap. Prompt/Text nodes remain uncapped.
2026-06-06 08:12:09 +08:00
Will Miao
d9ee9b3155 fix(utils): catch MemoryError in read_safetensors_metadata for non-safetensors files 2026-06-06 07:35:36 +08:00
pixelpaws
01dac57c35 Merge pull request #959 from id-fa/fix/lora-loader-list-scroll-nodes2
fix(ui): make Lora Loader list scrollable in Nodes 2.0 mode
2026-06-06 07:33:19 +08:00
id-fa
7f92d09239 fix(ui): make Lora Loader list scrollable in Nodes 2.0 mode
In Nodes 2.0 / Vue node mode the Lora Loader list could not be capped
and the node grew to show every row, unlike classic mode which fixes the
list area to 12 rows. The Vue layout engine measures the rendered DOM, so
CSS variables and computeLayoutSize alone were ignored.

- Physically cap the container via max-height so the rendered element is
  bounded to the 12-row height; extra rows scroll (overflow: auto).
- Report the capped height through computeSize / computeLayoutSize /
  getHeight / getMinHeight so the node background matches the list.
- Add enableListWheelScroll: a window capture-phase wheel hook that scrolls
  the hovered list instead of letting ComfyUI zoom the canvas, which fires
  on the document/canvas in capture and beat a container-level listener.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:29:01 +09:00
Will Miao
62f9e3f44a fix(scripts): use platformdirs for cross-platform settings path resolution
Both restore_suffixed_filenames.py and migrate_legacy_metadata.py
hardcoded Path.home() / '.config' / APP_NAME for finding settings.json,
which only works on Linux. On Windows this resolves to the wrong path
(~/.config/ instead of %LOCALAPPDATA%).

Replace the hand-rolled fallback with platformdirs.user_config_dir(),
which correctly resolves to the OS-appropriate config directory on all
platforms (Windows: %%LOCALAPPDATA%%, macOS: ~/Library/Application Support,
Linux: ~/.config). The portable mode check (settings.json in repo root
with use_portable_settings: true) is preserved unchanged.
2026-06-04 07:17:53 +08:00
willmiao
e55895786d docs: auto-update supporters list in README 2026-06-03 14:30:44 +00:00
Will Miao
82b77bf593 chore(release): bump version to v1.0.11 2026-06-03 22:30:21 +08:00
Will Miao
1beef5dea9 fix(ui): show title tooltips on disabled showcase media control buttons 2026-06-03 20:33:58 +08:00
Will Miao
c8beaa64e1 feat(scripts): add restore_suffixed_filenames script to revert leftover hash suffixes 2026-06-03 20:06:42 +08:00
Will Miao
fb443ed6ae perf(recipe): skip CivitAI API calls for locally-known models in create-from-example (#945)
Build a local_cache from the scanner cache before calling the metadata
parser. When a resource hash is found in the cache, populate the entry
directly from cached civitai metadata instead of calling CivitAI's
/model-versions/by-hash endpoint.

This eliminates redundant API calls and retries for the common case
where the example image only uses the parent model plus a checkpoint.
2026-06-03 19:16:52 +08:00
Will Miao
151a467598 feat(recipe): add Create As Recipe from example images with import dedup check (#945) 2026-06-03 19:16:52 +08:00
Will Miao
98e1d168b0 feat(utils): add AutoV2 and AutoV3 hash calculation functions 2026-06-03 19:16:35 +08:00
Will Miao
716f18e0ed chore: remove 'Describe alternatives' section from feature request template 2026-06-02 20:45:43 +08:00
Will Miao
b060dc99fc feat(download): add skip-download endpoint that cancels in-memory tracking while preserving partial files on disk 2026-06-02 20:38:47 +08:00
Will Miao
54bcdfab38 fix(test): add folder_path param to DummyUpdateService to match updated interface 2026-06-02 19:02:18 +08:00
Will Miao
2e7532eecc feat(update): add per-folder update check via sidebar context menu (#944) 2026-06-02 18:34:01 +08:00
Will Miao
7e5e3b1ec7 feat(download): support multi-precision file selection for CivitAI model downloads (#956) 2026-06-02 15:41:42 +08:00
Will Miao
df67bd396a fix(recipe): re-export syncChanges and add show mock to fix test 2026-06-02 11:02:20 +08:00
Will Miao
dd5d9cfcb2 fix(recipe): align refresh split button behavior with models page
- refreshRecipes() now accepts fullRebuild param and passes it to scan endpoint
- Use consistent toast.api.refreshComplete / toast.api.refreshFailed keys
- Use loadingManager.show() with progress bar (matching models page style)
- Both Refresh and Rebuild Cache now hit the real /api/lm/recipes/scan endpoint
- Add sidebarManager.refresh() after recipe scan completes
- Backend scan_recipes handler reads full_rebuild query param
2026-06-02 09:50:59 +08:00
Will Miao
d9fd60bec1 fix(recipe): use VirtualScroller pageSize in reload helpers to prevent pagination offset gap 2026-06-02 08:43:30 +08:00
Will Miao
b633b22779 fix(recipe): prevent empty grid by removing preserveScroll from refresh triggers
Bug: when scrolling down on recipes page, any operation with
preserveScroll: true would fetch only page 1 data then restore
scroll position to beyond the loaded items, leaving the grid empty.

Fix:
- Remove preserveScroll: true from all 7 must-refresh trigger
  paths (filter, search, sort, import, settings reload, sync,
  rebuild cache, sidebar folder nav)
- Replace full list refresh with updateSingleItem() for repair
  and bulk missing-LoRA download operations
- Update tests to match new scroll-free behavior
2026-06-02 08:15:29 +08:00
Will Miao
1ffa543160 fix(recipe): set dataset.favorite on recipe cards for correct bulk favorite menu 2026-06-02 07:06:58 +08:00
Will Miao
cdc940586e fix(civarchive): infer metadata.format from extension and prioritize safetensors in file list 2026-06-01 22:07:55 +08:00
Will Miao
ccf1c6f2ae fix(recipe): resolve base_model from parser and prevent empty checkpoint save on CivitAI import
- Apply CivitaiApiMetadataParser's base_model result to metadata in
  _do_import_remote_recipe and _do_import_from_url (was previously discarded)
- Extract baseModel from raw civitai_info before populate_checkpoint_from_civitai
  so it's not lost when the type check rejects non-checkpoint model versions
- Only format and save checkpoint entry when it has real data (modelId, versionId,
  name, or version), preventing empty {'type': 'checkpoint'} stubs
2026-06-01 17:58:08 +08:00
Will Miao
bfe7b5e1c7 fix(constants): add missing diffusion model base models (Flux, DiT, video, etc.) 2026-05-31 17:12:09 +08:00
Will Miao
85c020cd12 fix(update): preserve wildcards, backups dirs during ZIP upgrade, add log rotation
- Add wildcards and backups to skip_files in all three ZIP upgrade
  skip locations: _clean_plugin_folder, copy loop, .tracking generation
- Remove logs from skip_files (logs are transient and rotate automatically)
- Add _prune_old_logs() to session_logging.py: keeps only the 3 newest
  session log files, deletes older ones on each standalone startup
2026-05-31 15:56:56 +08:00
Will Miao
1b202f8ec7 fix(autocomplete): escape parentheses in prompt tag insertion (#951) 2026-05-31 15:40:19 +08:00
Will Miao
d02a0611d3 fix(update): close SQLite connection and protect cache dir during ZIP update
On Windows, shutil.rmtree() fails when deleting a directory that contains
an open SQLite database file. The ZIP update path in _download_and_replace_zip()
calls _clean_plugin_folder() which tries to delete the cache/ directory,
but downloaded_versions.sqlite is held open by DownloadedVersionHistoryService.

Fix:
- Add close() method to DownloadedVersionHistoryService to release
  the persistent SQLite connection
- Call close() before _clean_plugin_folder() in the ZIP update flow
- Add 'cache' to the skip_files list so the runtime cache directory is
  never deleted during plugin updates
2026-05-31 15:06:15 +08:00
pixelpaws
92166a161a Update Portable Package link to version 1.0.10 2026-05-31 10:08:28 +08:00
Will Miao
b509f27cb7 chore(release): bump version to v1.0.10 2026-05-31 09:39:26 +08:00
Will Miao
5c2ef48917 fix(aria2): apply certifi CA bundle to aria2c via --ca-certificate
When certifi is available, pass its CA bundle path as --ca-certificate
to the aria2c subprocess so that aria2 downloads use the same
certificate store as Python aiohttp downloads. Graceful fallback when
certifi is not installed.
2026-05-30 21:47:13 +08:00
Will Miao
ad2bd82c67 fix(downloader): use certifi CA bundle as SSL fallback and log SSL error diagnostics
- Prefer certifi's CA bundle in aiohttp SSL context with graceful
  fallback to system default when certifi is unavailable
- Add is_ssl_cert_verify_error() helper for SSL cert failure detection
- Log actionable error message (pip install --upgrade certifi /
  pip install pip-system-certs) when SSL certificate verification fails
- Apply same diagnostic logging to aria2 redirect resolution path
2026-05-30 21:28:18 +08:00
willmiao
17ba350153 docs: auto-update supporters list in README 2026-05-28 13:47:09 +00:00
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
hein
4e3ede23b7 feat: batch URL download for LoRA models
Add multi-URL batch download support to the download modal.
Users can paste multiple CivitAI URLs (one per line) in a textarea,
preview all parsed models in a compact list, optionally change versions
per model, select a unified download path, and batch download sequentially.

Single URL behavior is preserved unchanged.

Changes:
- Replace single-line input with textarea for multi-URL input
- Add batch preview step with compact list (thumbnail, version, size)
- Per-item version editing via existing version selector
- Batch download with WebSocket progress tracking (reuses existing infra)
- URL deduplication by model ID, preserving paste order
- Invalid URLs shown inline with remove option
- Fix: prevent click listener accumulation in showVersionStep

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 11:37:36 +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
196 changed files with 12140 additions and 1834 deletions

View File

@@ -13,8 +13,5 @@ A clear and concise description of what the problem is. Ex. I'm always frustrate
**Describe the solution you'd like** **Describe the solution you'd like**
A clear and concise description of what you want to happen. A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context** **Additional context**
Add any other context or screenshots about the feature request here. Add any other context or screenshots about the feature request here.

2
.gitignore vendored
View File

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

View File

@@ -0,0 +1,181 @@
# Embeddings Usage Tracking — Hybrid Approach (Plan C)
> **Status**: Reference document for future implementation
> **Current implementation**: Plan A (prompt text parsing only, see `usage_stats.py:_process_embeddings`)
> **Next step**: Add Plan B as a supplement when edge-case coverage is needed
## Problem
Embeddings in ComfyUI are not loaded through dedicated ComfyUI nodes like LoRAs or
Checkpoints. They are resolved during CLIP tokenization when the prompt text contains
`embedding:<name>` syntax (see `comfy/sd1_clip.py:SDTokenizer.tokenize_with_weights`).
This means the existing metadata_collector hook (which intercepts node execution via
`_map_node_over_list`) cannot capture embeddings the same way it captures LoRAs and
checkpoints — there is no "EmbeddingLoader" node to intercept.
## Solution Architecture
The hybrid approach combines **two complementary mechanisms** to capture embedding
usage from all possible paths.
```
┌─────────────────────────────────────────────────────────┐
│ Plan A (已实现) │
│ │
│ MetadataRegistry.prompt_metadata["prompts"] │
│ │ │
│ ▼ │
│ _process_embeddings() │
│ │ │
│ ├─ Iterate all prompt node texts │
│ ├─ regex extract "embedding:<name>" │
│ ├─ resolve name → sha256 via EmbeddingScanner │
│ └─ UsageStats.stats["embeddings"][sha256]++ │
│ │
│ Coverage: ~95% — all CLIPTextEncode/Flux/etc nodes │
│ │
│ Gap: Custom nodes that load embeddings programmatically │
│ without putting embedding:name in prompt text │
└─────────────────────────────────────────────────────────┘
+
↓ (future: enable Plan B when needed)
┌─────────────────────────────────────────────────────────┐
│ Plan B (未来 — monkey-patch) │
│ │
│ comfy/sd1_clip.py:load_embed() │
│ │ │
│ ▼ │
│ Monkey-patch intercepts EVERY embedding file load │
│ │ │
│ ├─ Records embedding_name + success/failure │
│ ├─ Associates with current prompt_id (via registry)│
│ └─ Feeds into UsageStats same as Plan A │
│ │
│ Coverage: 100% — catches ALL embedding loads │
│ │
│ Cost: Requires patching into ComfyUI internals │
│ (sd1_clip.py, sdxl_clip.py, some text_encoders) │
└─────────────────────────────────────────────────────────┘
```
## Plan B Detail — Monkey-patch `load_embed`
### Target Function
**`comfy.sd1_clip.load_embed(embedding_name, embedding_directory, embedding_size, embed_key=None)`**
at line 415 of `sd1_clip.py`.
This is the **single choke point** for all embedding file loads in ComfyUI. Every
CLIP variant (SD1, SDXL, SD3, Flux) calls this same function.
### Implementation Sketch
```python
# In metadata_collector/metadata_hook.py (or a new module)
import comfy.sd1_clip as sd1_clip
_original_load_embed = sd1_clip.load_embed
def _patched_load_embed(embedding_name, embedding_directory, embedding_size, embed_key=None):
result = _original_load_embed(
embedding_name, embedding_directory, embedding_size, embed_key
)
if result is not None:
_record_embedding_usage(embedding_name)
return result
sd1_clip.load_embed = _patched_load_embed
```
### Prompt ID Association
The challenge is associating the `load_embed` call with the current `prompt_id`.
Options:
1. **Thread-local / contextvar**: Store current `prompt_id` in a `contextvars.ContextVar`
that the metadata_collector sets at the start of each prompt execution.
2. **MetadataRegistry singleton**: The MetadataRegistry already has `current_prompt_id`.
The patch can read it directly since both run in the same thread.
3. **Lazy aggregation**: Instead of associating with prompt_id at load time, collect
all loaded embedding names in a global set during execution, then flush to
UsageStats after the prompt completes.
### Files to Patch
| File | Function | Coverage |
|------|----------|----------|
| `comfy/sd1_clip.py:415` | `load_embed()` | Primary — SD1.x, SDXL, SD3, Flux |
| `comfy/sdxl_clip.py` | Not needed (calls `sd1_clip.SDTokenizer`) | — |
| `comfy/text_encoders/sd3_clip.py` | Not needed (calls `sd1_clip.SDTokenizer`) | — |
| `comfy/text_encoders/flux.py` | Not needed (calls `sd1_clip.SDTokenizer`) | — |
The SD1 tokenizer is the base class for all CLIP variants' tokenizers, so patching
`load_embed` covers them all.
### Edge Cases
| Edge Case | Plan A | Plan B |
|-----------|--------|--------|
| `embedding:name` in CLIPTextEncode | ✅ | ✅ |
| `embedding:name` in CLIPTextEncodeFlux | ✅ | ✅ |
| `embedding:name` in PromptLM (LoRA Manager) | ✅ | ✅ |
| `embedding:name` in WAS_Text_to_Conditioning | ✅ | ✅ |
| Custom node that loads embedding programmatically | ❌ | ✅ |
| Embedding loaded multiple times in same prompt | ✅ (dedup via set) | ✅ (dedup via set) |
| Embedding file not found | N/A | ✅ (can log) |
| Embedding dimension mismatch | N/A | ✅ (can log) |
| Text encoder with non-standard tokenizer (LLaMA, T5...) | Partial | ✅ (if it calls load_embed) |
## Migration Path: Standalone → Hybrid
### Phase 1 — Plan A (当前状态)
- Prompt text parsing only
- No monkey-patching required
- Covers all standard workflows
### Phase 2 — Enable Plan B (未来工作)
1. Add monkey-patch of `load_embed` in `metadata_collector/metadata_hook.py` (alongside
the existing `_map_node_over_list` hook)
2. Collect loaded embedding names in a `set()` on the registry
3. In `UsageStats._process_embeddings()`, merge the Plan A results (from prompt text)
with the Plan B results (from the patch)
4. Add `prompt_data` field on MetadataRegistry to store loaded embeddings per prompt
### Deduplication
```python
# Merge Plan A + Plan B results in _process_embeddings
plan_a_names = extract_from_prompt_texts(prompts_data)
plan_b_names = registry.get_loaded_embeddings(prompt_id)
all_names = plan_a_names | plan_b_names
```
## Testing the Hybrid
| Scenario | What to verify |
|----------|---------------|
| Standard `embedding:name` in prompt | Plan A captures it |
| Embedding loaded by custom node script | Plan B captures it |
| Both paths fire for same embedding | No double-counting (dedup) |
| Embedding name resolves to hash | EmbeddingScanner.get_hash_by_filename works |
| No embedding scanner available | Graceful skip, no crash |
| Missing embedding file | Plan B logs warning, Plan A skips gracefully |
| Empty prompt | No crash, no entries |
| Standalone mode | Both plans disabled gracefully |
## Key Files Reference
| File | Role |
|------|------|
| `py/utils/usage_stats.py` | Core — `_process_embeddings()` for Plan A |
| `py/metadata_collector/constants.py` | `EMBEDDINGS` category constant |
| `py/metadata_collector/metadata_hook.py` | Future — monkey-patch for Plan B |
| `py/services/embedding_scanner.py` | Hash resolution service |
| `py/routes/stats_routes.py` | Already handles `usage_data.get('embeddings', {})` |
| `comfy/sd1_clip.py` (ComfyUI) | `load_embed()` — Plan B target |

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,9 @@
"help": "Hilfe", "help": "Hilfe",
"add": "Hinzufügen", "add": "Hinzufügen",
"close": "Schließen", "close": "Schließen",
"menu": "Menü" "menu": "Menü",
"remove": "Entfernen",
"change": "Ändern"
}, },
"status": { "status": {
"loading": "Wird geladen...", "loading": "Wird geladen...",
@@ -111,6 +113,7 @@
"replacePreview": "Vorschau ersetzen", "replacePreview": "Vorschau ersetzen",
"copyCheckpointName": "Checkpoint-Name kopieren", "copyCheckpointName": "Checkpoint-Name kopieren",
"copyEmbeddingName": "Embedding-Name kopieren", "copyEmbeddingName": "Embedding-Name kopieren",
"embeddingNameCopied": "Embedding-Syntax kopiert",
"sendCheckpointToWorkflow": "An ComfyUI senden", "sendCheckpointToWorkflow": "An ComfyUI senden",
"sendEmbeddingToWorkflow": "An ComfyUI senden" "sendEmbeddingToWorkflow": "An ComfyUI senden"
}, },
@@ -232,7 +235,10 @@
"license": "Lizenz", "license": "Lizenz",
"noCreditRequired": "Kein Credit erforderlich", "noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt", "allowSellingGeneratedContent": "Verkauf erlaubt",
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
"noTags": "Keine Tags", "noTags": "Keine Tags",
"autoTags": "Auto-Tags",
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.", "noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
"clearAll": "Alle Filter löschen", "clearAll": "Alle Filter löschen",
"any": "Beliebig", "any": "Beliebig",
@@ -266,10 +272,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "Download-Backend", "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": { "options": {
"python": "Python (integriert)", "python": "Python (integriert)",
"aria2": "aria2 (experimentell)" "aria2": "aria2 (empfohlen)"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -576,7 +582,13 @@
}, },
"misc": { "misc": {
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen", "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": { "metadataArchive": {
"enableArchiveDb": "Metadaten-Archiv-Datenbank aktivieren", "enableArchiveDb": "Metadaten-Archiv-Datenbank aktivieren",
@@ -640,8 +652,6 @@
}, },
"refresh": { "refresh": {
"title": "Modelliste aktualisieren", "title": "Modelliste aktualisieren",
"quick": "Änderungen synchronisieren",
"quickTooltip": "Nach neuen oder fehlenden Modelldateien suchen, damit die Liste aktuell bleibt.",
"full": "Cache neu aufbauen", "full": "Cache neu aufbauen",
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen." "fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
}, },
@@ -682,6 +692,8 @@
"setContentRating": "Inhaltsbewertung für alle festlegen", "setContentRating": "Inhaltsbewertung für alle festlegen",
"copyAll": "Alle Syntax kopieren", "copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren", "refreshAll": "Alle Metadaten aktualisieren",
"repairMetadata": "Metadaten der Auswahl reparieren",
"reimportMetadata": "Aus Quelle neu importieren",
"checkUpdates": "Auswahl auf Updates prüfen", "checkUpdates": "Auswahl auf Updates prüfen",
"moveAll": "Alle in Ordner verschieben", "moveAll": "Alle in Ordner verschieben",
"autoOrganize": "Automatisch organisieren", "autoOrganize": "Automatisch organisieren",
@@ -692,9 +704,18 @@
"unfavorite": "Aus Favoriten entfernen", "unfavorite": "Aus Favoriten entfernen",
"deleteAll": "Ausgewählte löschen", "deleteAll": "Ausgewählte löschen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen", "downloadMissingLoras": "Fehlende LoRAs herunterladen",
"downloadExamples": "Beispielbilder herunterladen",
"clear": "Auswahl löschen", "clear": "Auswahl löschen",
"skipMetadataRefreshCount": "Überspringen{count} Modelle", "skipMetadataRefreshCount": "Überspringen{count} Modelle",
"resumeMetadataRefreshCount": "Fortsetzen{count} Modelle", "resumeMetadataRefreshCount": "Fortsetzen{count} Modelle",
"sendToWorkflow": "An Workflow senden",
"sections": {
"workflow": "Workflow",
"metadata": "Metadaten",
"attributes": "Attribute",
"organize": "Organisieren",
"download": "Download"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "Automatische Organisation wird initialisiert...", "initializing": "Automatische Organisation wird initialisiert...",
"starting": "Automatische Organisation für {type} wird gestartet...", "starting": "Automatische Organisation für {type} wird gestartet...",
@@ -720,6 +741,7 @@
"setContentRating": "Inhaltsbewertung festlegen", "setContentRating": "Inhaltsbewertung festlegen",
"moveToFolder": "In Ordner verschieben", "moveToFolder": "In Ordner verschieben",
"repairMetadata": "Metadaten reparieren", "repairMetadata": "Metadaten reparieren",
"reimportMetadata": "Aus Quelle neu importieren",
"excludeModel": "Modell ausschließen", "excludeModel": "Modell ausschließen",
"restoreModel": "Modell wiederherstellen", "restoreModel": "Modell wiederherstellen",
"deleteModel": "Modell löschen", "deleteModel": "Modell löschen",
@@ -807,8 +829,6 @@
}, },
"refresh": { "refresh": {
"title": "Rezeptliste aktualisieren", "title": "Rezeptliste aktualisieren",
"quick": "Änderungen synchronisieren",
"quickTooltip": "Änderungen synchronisieren - schnelle Aktualisierung ohne Cache-Neubau",
"full": "Cache neu aufbauen", "full": "Cache neu aufbauen",
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien" "fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
}, },
@@ -849,6 +869,13 @@
"skipped": "Rezept bereits in der neuesten Version, keine Reparatur erforderlich", "skipped": "Rezept bereits in der neuesten Version, keine Reparatur erforderlich",
"failed": "Rezept-Reparatur fehlgeschlagen: {message}", "failed": "Rezept-Reparatur fehlgeschlagen: {message}",
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID" "missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
},
"reimport": {
"starting": "Rezept wird aus Quelle neu importiert...",
"success": "Rezept erfolgreich neu importiert",
"noSourceUrl": "Rezept hat keine Quell-URL, Neuimport nicht möglich",
"failed": "Neuimport des Rezepts fehlgeschlagen: {message}",
"missingId": "Neuimport nicht möglich: Rezept-ID fehlt"
} }
}, },
"batchImport": { "batchImport": {
@@ -926,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Stammverzeichnis", "modelRoot": "Stammverzeichnis",
"moreOptions": "Weitere Optionen",
"collapseAll": "Alle Ordner einklappen", "collapseAll": "Alle Ordner einklappen",
"pinSidebar": "Sidebar anheften", "pinSidebar": "Sidebar anheften",
"unpinSidebar": "Sidebar lösen", "unpinSidebar": "Sidebar lösen",
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
"showSidebar": "Seitenleiste anzeigen",
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
"switchToListView": "Zur Listenansicht wechseln", "switchToListView": "Zur Listenansicht wechseln",
"switchToTreeView": "Zur Baumansicht wechseln", "switchToTreeView": "Zur Baumansicht wechseln",
"recursiveOn": "Unterordner einbeziehen", "recursiveOn": "Unterordner einbeziehen",
@@ -948,6 +979,13 @@
"empty": { "empty": {
"noFolders": "Keine Ordner gefunden", "noFolders": "Keine Ordner gefunden",
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen" "dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
},
"folderUpdateCheck": {
"label": "Auf Updates in diesem Ordner prüfen",
"loading": "Prüfe {type}-Updates in diesem Ordner...",
"success": "{count} Update(s) für {type}s in diesem Ordner gefunden",
"none": "Alle {type}s in diesem Ordner sind aktuell",
"error": "Fehler beim Prüfen des Ordners auf {type}-Updates: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -992,9 +1030,9 @@
"download": { "download": {
"title": "Modell von URL herunterladen", "title": "Modell von URL herunterladen",
"titleWithType": "{type} von URL herunterladen", "titleWithType": "{type} von URL herunterladen",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL:", "civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "Geben Sie eine CivitAI- oder CivArchive-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
"locationPreview": "Download-Speicherort Vorschau", "locationPreview": "Download-Speicherort Vorschau",
"useDefaultPath": "Standardpfad verwenden", "useDefaultPath": "Standardpfad verwenden",
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert", "useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
@@ -1016,6 +1054,11 @@
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.", "downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
"alreadyInLibrary": "Bereits in Bibliothek", "alreadyInLibrary": "Bereits in Bibliothek",
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]", "autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
"fileSelection": {
"title": "Dateiformat auswählen",
"files": "Dateien",
"select": "Datei auswählen"
},
"errors": { "errors": {
"invalidUrl": "Ungültiges Civitai URL-Format", "invalidUrl": "Ungültiges Civitai URL-Format",
"noVersions": "Keine Versionen für dieses Modell verfügbar" "noVersions": "Keine Versionen für dieses Modell verfügbar"
@@ -1080,6 +1123,12 @@
"countMessage": "Modelle werden dauerhaft gelöscht.", "countMessage": "Modelle werden dauerhaft gelöscht.",
"action": "Alle löschen" "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": { "checkUpdates": {
"title": "Alle {typePlural} auf Updates prüfen?", "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.", "message": "Damit werden alle {typePlural} in deiner Bibliothek auf Updates geprüft. Bei großen Sammlungen kann das etwas länger dauern.",
@@ -1160,6 +1209,7 @@
"editModelName": "Modellname bearbeiten", "editModelName": "Modellname bearbeiten",
"editFileName": "Dateiname bearbeiten", "editFileName": "Dateiname bearbeiten",
"editBaseModel": "Basis-Modell bearbeiten", "editBaseModel": "Basis-Modell bearbeiten",
"editVersionName": "Versionsname bearbeiten",
"viewOnCivitai": "Auf Civitai anzeigen", "viewOnCivitai": "Auf Civitai anzeigen",
"viewOnCivitaiText": "Auf Civitai anzeigen", "viewOnCivitaiText": "Auf Civitai anzeigen",
"viewCreatorProfile": "Ersteller-Profil anzeigen", "viewCreatorProfile": "Ersteller-Profil anzeigen",
@@ -1191,7 +1241,9 @@
}, },
"notes": { "notes": {
"saved": "Notizen erfolgreich gespeichert", "saved": "Notizen erfolgreich gespeichert",
"saveFailed": "Fehler beim Speichern der Notizen" "saveFailed": "Fehler beim Speichern der Notizen",
"showMore": "Mehr anzeigen",
"showLess": "Weniger anzeigen"
}, },
"usageTips": { "usageTips": {
"addPresetParameter": "Voreingestellten Parameter hinzufügen...", "addPresetParameter": "Voreingestellten Parameter hinzufügen...",
@@ -1453,11 +1505,14 @@
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar", "noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
"noTargetNodeSelected": "Kein Zielknoten ausgewählt", "noTargetNodeSelected": "Kein Zielknoten ausgewählt",
"modelUpdated": "Modell im Workflow aktualisiert", "modelUpdated": "Modell im Workflow aktualisiert",
"modelFailed": "Fehler beim Aktualisieren des Modellknotens" "modelFailed": "Fehler beim Aktualisieren des Modellknotens",
"embeddingAdded": "Embedding zum Workflow hinzugefügt",
"embeddingFailed": "Fehler beim Hinzufügen des Embeddings"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Rezept", "recipe": "Rezept",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding",
"replace": "Ersetzen", "replace": "Ersetzen",
"append": "Anhängen", "append": "Anhängen",
"selectTargetNode": "Zielknoten auswählen", "selectTargetNode": "Zielknoten auswählen",
@@ -1634,6 +1689,10 @@
"noRecipeId": "Keine Rezept-ID verfügbar", "noRecipeId": "Keine Rezept-ID verfügbar",
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}", "sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}", "copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",
"createError": "Fehler beim Erstellen des Rezepts{message}",
"createFailed": "Fehler beim Erstellen des Rezepts{error}",
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
"created": "Rezept erfolgreich erstellt",
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen", "noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs", "missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
"preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download", "preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download",
@@ -1672,6 +1731,13 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt", "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}",
"reimporting": "Rezept wird aus Quelle neu importiert...",
"reimportSuccess": "Rezept erfolgreich neu importiert",
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden", "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." "noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
}, },
@@ -1909,9 +1975,32 @@
"warning": "Handlungsbedarf", "warning": "Handlungsbedarf",
"error": "Aktion erforderlich" "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": { "actions": {
"runAgain": "Erneut ausführen", "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": { "toast": {
"loadFailed": "Diagnose konnte nicht geladen werden: {message}", "loadFailed": "Diagnose konnte nicht geladen werden: {message}",
@@ -1923,6 +2012,15 @@
"conflictsResolveFailed": "Auflösung der Dateinamenskonflikte fehlgeschlagen: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "Anwendungs-Update erkannt", "title": "Anwendungs-Update erkannt",

View File

@@ -16,7 +16,9 @@
"help": "Help", "help": "Help",
"add": "Add", "add": "Add",
"close": "Close", "close": "Close",
"menu": "Menu" "menu": "Menu",
"remove": "Remove",
"change": "Change"
}, },
"status": { "status": {
"loading": "Loading...", "loading": "Loading...",
@@ -111,6 +113,7 @@
"replacePreview": "Replace Preview", "replacePreview": "Replace Preview",
"copyCheckpointName": "Copy checkpoint name", "copyCheckpointName": "Copy checkpoint name",
"copyEmbeddingName": "Copy embedding name", "copyEmbeddingName": "Copy embedding name",
"embeddingNameCopied": "Embedding syntax copied",
"sendCheckpointToWorkflow": "Send to ComfyUI", "sendCheckpointToWorkflow": "Send to ComfyUI",
"sendEmbeddingToWorkflow": "Send to ComfyUI" "sendEmbeddingToWorkflow": "Send to ComfyUI"
}, },
@@ -232,7 +235,10 @@
"license": "License", "license": "License",
"noCreditRequired": "No Credit Required", "noCreditRequired": "No Credit Required",
"allowSellingGeneratedContent": "Allow Selling", "allowSellingGeneratedContent": "Allow Selling",
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
"noCreditRequiredTooltip": "Use the model without crediting the creator",
"noTags": "No tags", "noTags": "No tags",
"autoTags": "Auto Tags",
"noBaseModelMatches": "No base models match the current search.", "noBaseModelMatches": "No base models match the current search.",
"clearAll": "Clear All Filters", "clearAll": "Clear All Filters",
"any": "Any", "any": "Any",
@@ -266,10 +272,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "Download backend", "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": { "options": {
"python": "Python (built-in)", "python": "Python (built-in)",
"aria2": "aria2 (experimental)" "aria2": "aria2 (recommended)"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -576,7 +582,13 @@
}, },
"misc": { "misc": {
"includeTriggerWords": "Include Trigger Words in LoRA Syntax", "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": { "metadataArchive": {
"enableArchiveDb": "Enable Metadata Archive Database", "enableArchiveDb": "Enable Metadata Archive Database",
@@ -640,8 +652,6 @@
}, },
"refresh": { "refresh": {
"title": "Refresh model list", "title": "Refresh model list",
"quick": "Sync Changes",
"quickTooltip": "Scan for new or missing model files so the list stays current.",
"full": "Rebuild Cache", "full": "Rebuild Cache",
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits." "fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
}, },
@@ -682,6 +692,8 @@
"setContentRating": "Set Content Rating for Selected", "setContentRating": "Set Content Rating for Selected",
"copyAll": "Copy Selected Syntax", "copyAll": "Copy Selected Syntax",
"refreshAll": "Refresh Selected Metadata", "refreshAll": "Refresh Selected Metadata",
"repairMetadata": "Repair Metadata for Selected",
"reimportMetadata": "Re-import from Source",
"checkUpdates": "Check Updates for Selected", "checkUpdates": "Check Updates for Selected",
"moveAll": "Move Selected to Folder", "moveAll": "Move Selected to Folder",
"autoOrganize": "Auto-Organize Selected", "autoOrganize": "Auto-Organize Selected",
@@ -692,9 +704,18 @@
"unfavorite": "Remove from Favorites", "unfavorite": "Remove from Favorites",
"deleteAll": "Delete Selected", "deleteAll": "Delete Selected",
"downloadMissingLoras": "Download Missing LoRAs", "downloadMissingLoras": "Download Missing LoRAs",
"downloadExamples": "Download Example Images",
"clear": "Clear Selection", "clear": "Clear Selection",
"skipMetadataRefreshCount": "Skip ({count} models)", "skipMetadataRefreshCount": "Skip ({count} models)",
"resumeMetadataRefreshCount": "Resume ({count} models)", "resumeMetadataRefreshCount": "Resume ({count} models)",
"sendToWorkflow": "Send to Workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Metadata",
"attributes": "Attributes",
"organize": "Organize",
"download": "Download"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "Initializing auto-organize...", "initializing": "Initializing auto-organize...",
"starting": "Starting auto-organize for {type}...", "starting": "Starting auto-organize for {type}...",
@@ -720,6 +741,7 @@
"setContentRating": "Set Content Rating", "setContentRating": "Set Content Rating",
"moveToFolder": "Move to Folder", "moveToFolder": "Move to Folder",
"repairMetadata": "Repair metadata", "repairMetadata": "Repair metadata",
"reimportMetadata": "Re-import from Source",
"excludeModel": "Exclude Model", "excludeModel": "Exclude Model",
"restoreModel": "Restore Model", "restoreModel": "Restore Model",
"deleteModel": "Delete Model", "deleteModel": "Delete Model",
@@ -807,8 +829,6 @@
}, },
"refresh": { "refresh": {
"title": "Refresh recipe list", "title": "Refresh recipe list",
"quick": "Sync Changes",
"quickTooltip": "Sync changes - quick refresh without rebuilding cache",
"full": "Rebuild Cache", "full": "Rebuild Cache",
"fullTooltip": "Rebuild cache - full rescan of all recipe files" "fullTooltip": "Rebuild cache - full rescan of all recipe files"
}, },
@@ -849,6 +869,13 @@
"skipped": "Recipe already at latest version, no repair needed", "skipped": "Recipe already at latest version, no repair needed",
"failed": "Failed to repair recipe: {message}", "failed": "Failed to repair recipe: {message}",
"missingId": "Cannot repair recipe: Missing recipe ID" "missingId": "Cannot repair recipe: Missing recipe ID"
},
"reimport": {
"starting": "Re-importing recipe from source...",
"success": "Recipe re-imported successfully",
"noSourceUrl": "Recipe has no source URL, cannot re-import",
"failed": "Failed to re-import recipe: {message}",
"missingId": "Cannot re-import recipe: Missing recipe ID"
} }
}, },
"batchImport": { "batchImport": {
@@ -926,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Root", "modelRoot": "Root",
"moreOptions": "More options",
"collapseAll": "Collapse All Folders", "collapseAll": "Collapse All Folders",
"pinSidebar": "Pin Sidebar", "pinSidebar": "Pin Sidebar",
"unpinSidebar": "Unpin Sidebar", "unpinSidebar": "Unpin Sidebar",
"hideOnThisPage": "Hide sidebar on this page",
"showSidebar": "Show sidebar",
"sidebarHiddenNotification": "Folder sidebar hidden on {page} page",
"switchToListView": "Switch to List View", "switchToListView": "Switch to List View",
"switchToTreeView": "Switch to Tree View", "switchToTreeView": "Switch to Tree View",
"recursiveOn": "Include subfolders", "recursiveOn": "Include subfolders",
@@ -948,6 +979,13 @@
"empty": { "empty": {
"noFolders": "No folders found", "noFolders": "No folders found",
"dragHint": "Drag items here to create folders" "dragHint": "Drag items here to create folders"
},
"folderUpdateCheck": {
"label": "Check for updates in this folder",
"loading": "Checking {type} updates for this folder...",
"success": "Found {count} update(s) for {type}s in this folder",
"none": "All {type}s in this folder are up to date",
"error": "Failed to check folder for {type} updates: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -992,9 +1030,9 @@
"download": { "download": {
"title": "Download Model from URL", "title": "Download Model from URL",
"titleWithType": "Download {type} from URL", "titleWithType": "Download {type} from URL",
"url": "Civitai URL", "civitaiUrl": "Civitai URL(s):",
"civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "Enter one CivitAI or CivArchive URL per line. Supports multiple URLs for batch download.",
"locationPreview": "Download Location Preview", "locationPreview": "Download Location Preview",
"useDefaultPath": "Use Default Path", "useDefaultPath": "Use Default Path",
"useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates", "useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates",
@@ -1016,6 +1054,11 @@
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.", "downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
"alreadyInLibrary": "Already in Library", "alreadyInLibrary": "Already in Library",
"autoOrganizedPath": "[Auto-organized by path template]", "autoOrganizedPath": "[Auto-organized by path template]",
"fileSelection": {
"title": "Select File Format",
"files": "files",
"select": "Select File"
},
"errors": { "errors": {
"invalidUrl": "Invalid Civitai URL format", "invalidUrl": "Invalid Civitai URL format",
"noVersions": "No versions available for this model" "noVersions": "No versions available for this model"
@@ -1080,6 +1123,12 @@
"countMessage": "models will be permanently deleted.", "countMessage": "models will be permanently deleted.",
"action": "Delete All" "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": { "checkUpdates": {
"title": "Check updates for all {typePlural}?", "title": "Check updates for all {typePlural}?",
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.", "message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",
@@ -1160,6 +1209,7 @@
"editModelName": "Edit model name", "editModelName": "Edit model name",
"editFileName": "Edit file name", "editFileName": "Edit file name",
"editBaseModel": "Edit base model", "editBaseModel": "Edit base model",
"editVersionName": "Edit version name",
"viewOnCivitai": "View on Civitai", "viewOnCivitai": "View on Civitai",
"viewOnCivitaiText": "View on Civitai", "viewOnCivitaiText": "View on Civitai",
"viewCreatorProfile": "View Creator Profile", "viewCreatorProfile": "View Creator Profile",
@@ -1191,7 +1241,9 @@
}, },
"notes": { "notes": {
"saved": "Notes saved successfully", "saved": "Notes saved successfully",
"saveFailed": "Failed to save notes" "saveFailed": "Failed to save notes",
"showMore": "Show more",
"showLess": "Show less"
}, },
"usageTips": { "usageTips": {
"addPresetParameter": "Add preset parameter...", "addPresetParameter": "Add preset parameter...",
@@ -1453,11 +1505,14 @@
"noMatchingNodes": "No compatible nodes available in the current workflow", "noMatchingNodes": "No compatible nodes available in the current workflow",
"noTargetNodeSelected": "No target node selected", "noTargetNodeSelected": "No target node selected",
"modelUpdated": "Model updated in workflow", "modelUpdated": "Model updated in workflow",
"modelFailed": "Failed to update model node" "modelFailed": "Failed to update model node",
"embeddingAdded": "Embedding added to workflow",
"embeddingFailed": "Failed to add embedding"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Recipe", "recipe": "Recipe",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding",
"replace": "Replace", "replace": "Replace",
"append": "Append", "append": "Append",
"selectTargetNode": "Select target node", "selectTargetNode": "Select target node",
@@ -1634,6 +1689,10 @@
"noRecipeId": "No recipe ID available", "noRecipeId": "No recipe ID available",
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}", "sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
"copyFailed": "Error copying recipe syntax: {message}", "copyFailed": "Error copying recipe syntax: {message}",
"createError": "Error creating recipe: {message}",
"createFailed": "Failed to create recipe: {error}",
"createMissingData": "Missing required data to create recipe",
"created": "Recipe created successfully",
"noMissingLoras": "No missing LoRAs to download", "noMissingLoras": "No missing LoRAs to download",
"missingLorasInfoFailed": "Failed to get information for missing LoRAs", "missingLorasInfoFailed": "Failed to get information for missing LoRAs",
"preparingForDownloadFailed": "Error preparing LoRAs for download", "preparingForDownloadFailed": "Error preparing LoRAs for download",
@@ -1672,6 +1731,13 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No recipes selected", "noRecipesSelected": "No recipes selected",
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
"repairBulkFailed": "Failed to repair selected recipes: {message}",
"reimporting": "Re-importing recipe from source...",
"reimportSuccess": "Recipe re-imported successfully",
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
"reimportBulkFailed": "Failed to re-import some recipes",
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes", "noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings." "noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
}, },
@@ -1909,9 +1975,32 @@
"warning": "Needs Attention", "warning": "Needs Attention",
"error": "Action Required" "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": { "actions": {
"runAgain": "Run Again", "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": { "toast": {
"loadFailed": "Failed to load diagnostics: {message}", "loadFailed": "Failed to load diagnostics: {message}",
@@ -1923,6 +2012,15 @@
"conflictsResolveFailed": "Failed to resolve filename conflicts: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "Application Update Detected", "title": "Application Update Detected",

View File

@@ -16,7 +16,9 @@
"help": "Ayuda", "help": "Ayuda",
"add": "Añadir", "add": "Añadir",
"close": "Cerrar", "close": "Cerrar",
"menu": "Menú" "menu": "Menú",
"remove": "Eliminar",
"change": "Cambiar"
}, },
"status": { "status": {
"loading": "Cargando...", "loading": "Cargando...",
@@ -111,6 +113,7 @@
"replacePreview": "Reemplazar vista previa", "replacePreview": "Reemplazar vista previa",
"copyCheckpointName": "Copiar nombre del checkpoint", "copyCheckpointName": "Copiar nombre del checkpoint",
"copyEmbeddingName": "Copiar nombre del embedding", "copyEmbeddingName": "Copiar nombre del embedding",
"embeddingNameCopied": "Sintaxis de embedding copiada",
"sendCheckpointToWorkflow": "Enviar a ComfyUI", "sendCheckpointToWorkflow": "Enviar a ComfyUI",
"sendEmbeddingToWorkflow": "Enviar a ComfyUI" "sendEmbeddingToWorkflow": "Enviar a ComfyUI"
}, },
@@ -232,7 +235,10 @@
"license": "Licencia", "license": "Licencia",
"noCreditRequired": "Sin crédito requerido", "noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida", "allowSellingGeneratedContent": "Venta permitida",
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
"noTags": "Sin etiquetas", "noTags": "Sin etiquetas",
"autoTags": "Etiquetas automáticas",
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.", "noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
"clearAll": "Limpiar todos los filtros", "clearAll": "Limpiar todos los filtros",
"any": "Cualquiera", "any": "Cualquiera",
@@ -266,10 +272,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "Backend de descarga", "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": { "options": {
"python": "Python (integrado)", "python": "Python (integrado)",
"aria2": "aria2 (experimental)" "aria2": "aria2 (recomendado)"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -576,7 +582,13 @@
}, },
"misc": { "misc": {
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA", "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": { "metadataArchive": {
"enableArchiveDb": "Habilitar base de datos de archivo de metadatos", "enableArchiveDb": "Habilitar base de datos de archivo de metadatos",
@@ -640,8 +652,6 @@
}, },
"refresh": { "refresh": {
"title": "Actualizar lista de modelos", "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é", "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." "fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
}, },
@@ -682,6 +692,8 @@
"setContentRating": "Establecer clasificación de contenido para todos", "setContentRating": "Establecer clasificación de contenido para todos",
"copyAll": "Copiar toda la sintaxis", "copyAll": "Copiar toda la sintaxis",
"refreshAll": "Actualizar todos los metadatos", "refreshAll": "Actualizar todos los metadatos",
"repairMetadata": "Reparar metadatos de la selección",
"reimportMetadata": "Reimportar desde origen",
"checkUpdates": "Comprobar actualizaciones para la selección", "checkUpdates": "Comprobar actualizaciones para la selección",
"moveAll": "Mover todos a carpeta", "moveAll": "Mover todos a carpeta",
"autoOrganize": "Auto-organizar seleccionados", "autoOrganize": "Auto-organizar seleccionados",
@@ -692,9 +704,18 @@
"unfavorite": "Quitar de favoritos", "unfavorite": "Quitar de favoritos",
"deleteAll": "Eliminar seleccionados", "deleteAll": "Eliminar seleccionados",
"downloadMissingLoras": "Descargar LoRAs faltantes", "downloadMissingLoras": "Descargar LoRAs faltantes",
"downloadExamples": "Descargar imágenes de ejemplo",
"clear": "Limpiar selección", "clear": "Limpiar selección",
"skipMetadataRefreshCount": "Omitir{count} modelos", "skipMetadataRefreshCount": "Omitir{count} modelos",
"resumeMetadataRefreshCount": "Reanudar{count} modelos", "resumeMetadataRefreshCount": "Reanudar{count} modelos",
"sendToWorkflow": "Enviar al workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Metadatos",
"attributes": "Atributos",
"organize": "Organizar",
"download": "Descargar"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "Inicializando auto-organización...", "initializing": "Inicializando auto-organización...",
"starting": "Iniciando auto-organización para {type}...", "starting": "Iniciando auto-organización para {type}...",
@@ -720,6 +741,7 @@
"setContentRating": "Establecer clasificación de contenido", "setContentRating": "Establecer clasificación de contenido",
"moveToFolder": "Mover a carpeta", "moveToFolder": "Mover a carpeta",
"repairMetadata": "Reparar metadatos", "repairMetadata": "Reparar metadatos",
"reimportMetadata": "Reimportar desde origen",
"excludeModel": "Excluir modelo", "excludeModel": "Excluir modelo",
"restoreModel": "Restaurar modelo", "restoreModel": "Restaurar modelo",
"deleteModel": "Eliminar modelo", "deleteModel": "Eliminar modelo",
@@ -807,8 +829,6 @@
}, },
"refresh": { "refresh": {
"title": "Actualizar lista de recetas", "title": "Actualizar lista de recetas",
"quick": "Sincronizar cambios",
"quickTooltip": "Sincronizar cambios - actualización rápida sin reconstruir caché",
"full": "Reconstruir caché", "full": "Reconstruir caché",
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas" "fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
}, },
@@ -849,6 +869,13 @@
"skipped": "La receta ya está en la última versión, no se necesita reparación", "skipped": "La receta ya está en la última versión, no se necesita reparación",
"failed": "Error al reparar la receta: {message}", "failed": "Error al reparar la receta: {message}",
"missingId": "No se puede reparar la receta: falta el ID de la receta" "missingId": "No se puede reparar la receta: falta el ID de la receta"
},
"reimport": {
"starting": "Reimportando receta desde origen...",
"success": "Receta reimportada exitosamente",
"noSourceUrl": "La receta no tiene URL de origen, no se puede reimportar",
"failed": "Error al reimportar la receta: {message}",
"missingId": "No se puede reimportar la receta: falta el ID"
} }
}, },
"batchImport": { "batchImport": {
@@ -926,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Raíz", "modelRoot": "Raíz",
"moreOptions": "Más opciones",
"collapseAll": "Colapsar todas las carpetas", "collapseAll": "Colapsar todas las carpetas",
"pinSidebar": "Fijar barra lateral", "pinSidebar": "Fijar barra lateral",
"unpinSidebar": "Desfijar barra lateral", "unpinSidebar": "Desfijar barra lateral",
"hideOnThisPage": "Ocultar barra lateral en esta página",
"showSidebar": "Mostrar barra lateral",
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
"switchToListView": "Cambiar a vista de lista", "switchToListView": "Cambiar a vista de lista",
"switchToTreeView": "Cambiar a vista de árbol", "switchToTreeView": "Cambiar a vista de árbol",
"recursiveOn": "Incluir subcarpetas", "recursiveOn": "Incluir subcarpetas",
@@ -948,6 +979,13 @@
"empty": { "empty": {
"noFolders": "No se encontraron carpetas", "noFolders": "No se encontraron carpetas",
"dragHint": "Arrastra elementos aquí para crear carpetas" "dragHint": "Arrastra elementos aquí para crear carpetas"
},
"folderUpdateCheck": {
"label": "Buscar actualizaciones en esta carpeta",
"loading": "Buscando actualizaciones de {type} en esta carpeta...",
"success": "Se encontraron {count} actualización(es) para {type}s en esta carpeta",
"none": "Todos los {type}s en esta carpeta están actualizados",
"error": "Error al buscar actualizaciones de {type} en la carpeta: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -992,9 +1030,9 @@
"download": { "download": {
"title": "Descargar modelo desde URL", "title": "Descargar modelo desde URL",
"titleWithType": "Descargar {type} desde URL", "titleWithType": "Descargar {type} desde URL",
"url": "URL de Civitai",
"civitaiUrl": "URL de Civitai:", "civitaiUrl": "URL de Civitai:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "Ingrese una URL de CivitAI o CivArchive por línea. Admite múltiples URLs para descarga por lotes.",
"locationPreview": "Vista previa de ubicación de descarga", "locationPreview": "Vista previa de ubicación de descarga",
"useDefaultPath": "Usar ruta predeterminada", "useDefaultPath": "Usar ruta predeterminada",
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas", "useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
@@ -1016,6 +1054,11 @@
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.", "downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
"alreadyInLibrary": "Ya en la biblioteca", "alreadyInLibrary": "Ya en la biblioteca",
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]", "autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
"fileSelection": {
"title": "Seleccionar formato de archivo",
"files": "archivos",
"select": "Seleccionar archivo"
},
"errors": { "errors": {
"invalidUrl": "Formato de URL de Civitai inválido", "invalidUrl": "Formato de URL de Civitai inválido",
"noVersions": "No hay versiones disponibles para este modelo" "noVersions": "No hay versiones disponibles para este modelo"
@@ -1080,6 +1123,12 @@
"countMessage": "modelos serán eliminados permanentemente.", "countMessage": "modelos serán eliminados permanentemente.",
"action": "Eliminar todo" "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": { "checkUpdates": {
"title": "¿Comprobar actualizaciones para todos los {typePlural}?", "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.", "message": "Esto comprobará las actualizaciones de todos los {typePlural} de tu biblioteca. En colecciones grandes puede tardar un poco más.",
@@ -1160,6 +1209,7 @@
"editModelName": "Editar nombre del modelo", "editModelName": "Editar nombre del modelo",
"editFileName": "Editar nombre de archivo", "editFileName": "Editar nombre de archivo",
"editBaseModel": "Editar modelo base", "editBaseModel": "Editar modelo base",
"editVersionName": "Editar nombre de versión",
"viewOnCivitai": "Ver en Civitai", "viewOnCivitai": "Ver en Civitai",
"viewOnCivitaiText": "Ver en Civitai", "viewOnCivitaiText": "Ver en Civitai",
"viewCreatorProfile": "Ver perfil del creador", "viewCreatorProfile": "Ver perfil del creador",
@@ -1191,7 +1241,9 @@
}, },
"notes": { "notes": {
"saved": "Notas guardadas exitosamente", "saved": "Notas guardadas exitosamente",
"saveFailed": "Error al guardar notas" "saveFailed": "Error al guardar notas",
"showMore": "Mostrar más",
"showLess": "Mostrar menos"
}, },
"usageTips": { "usageTips": {
"addPresetParameter": "Añadir parámetro preestablecido...", "addPresetParameter": "Añadir parámetro preestablecido...",
@@ -1453,11 +1505,14 @@
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual", "noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino", "noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
"modelUpdated": "Modelo actualizado en el flujo de trabajo", "modelUpdated": "Modelo actualizado en el flujo de trabajo",
"modelFailed": "Error al actualizar nodo de modelo" "modelFailed": "Error al actualizar nodo de modelo",
"embeddingAdded": "Embedding añadido al flujo de trabajo",
"embeddingFailed": "Error al añadir el embedding"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Receta", "recipe": "Receta",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding",
"replace": "Reemplazar", "replace": "Reemplazar",
"append": "Añadir", "append": "Añadir",
"selectTargetNode": "Seleccionar nodo de destino", "selectTargetNode": "Seleccionar nodo de destino",
@@ -1634,6 +1689,10 @@
"noRecipeId": "No hay ID de receta disponible", "noRecipeId": "No hay ID de receta disponible",
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}", "sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}",
"copyFailed": "Error copiando sintaxis de receta: {message}", "copyFailed": "Error copiando sintaxis de receta: {message}",
"createError": "Error al crear la receta{message}",
"createFailed": "Error al crear la receta{error}",
"createMissingData": "Faltan datos necesarios para crear la receta",
"created": "Receta creada exitosamente",
"noMissingLoras": "No hay LoRAs faltantes para descargar", "noMissingLoras": "No hay LoRAs faltantes para descargar",
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes", "missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
"preparingForDownloadFailed": "Error preparando LoRAs para descarga", "preparingForDownloadFailed": "Error preparando LoRAs para descarga",
@@ -1672,6 +1731,13 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No se han seleccionado recetas", "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}",
"reimporting": "Reimportando receta desde origen...",
"reimportSuccess": "Receta reimportada exitosamente",
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
"reimportBulkFailed": "Error al reimportar algunas recetas",
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas", "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." "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."
}, },
@@ -1909,9 +1975,32 @@
"warning": "Requiere atención", "warning": "Requiere atención",
"error": "Se requiere acció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": { "actions": {
"runAgain": "Ejecutar de nuevo", "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": { "toast": {
"loadFailed": "Error al cargar los diagnósticos: {message}", "loadFailed": "Error al cargar los diagnósticos: {message}",
@@ -1923,6 +2012,15 @@
"conflictsResolveFailed": "Error al resolver conflictos de nombre de archivo: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "Actualización de la aplicación detectada", "title": "Actualización de la aplicación detectada",

View File

@@ -16,7 +16,9 @@
"help": "Aide", "help": "Aide",
"add": "Ajouter", "add": "Ajouter",
"close": "Fermer", "close": "Fermer",
"menu": "Menu" "menu": "Menu",
"remove": "Supprimer",
"change": "Modifier"
}, },
"status": { "status": {
"loading": "Chargement...", "loading": "Chargement...",
@@ -111,6 +113,7 @@
"replacePreview": "Remplacer l'aperçu", "replacePreview": "Remplacer l'aperçu",
"copyCheckpointName": "Copier le nom du checkpoint", "copyCheckpointName": "Copier le nom du checkpoint",
"copyEmbeddingName": "Copier le nom de l'embedding", "copyEmbeddingName": "Copier le nom de l'embedding",
"embeddingNameCopied": "Syntaxe dembedding copiée",
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI", "sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI" "sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
}, },
@@ -232,7 +235,10 @@
"license": "Licence", "license": "Licence",
"noCreditRequired": "Crédit non requis", "noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée", "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", "noTags": "Aucun tag",
"autoTags": "Auto-Tags",
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.", "noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
"clearAll": "Effacer tous les filtres", "clearAll": "Effacer tous les filtres",
"any": "N'importe quel", "any": "N'importe quel",
@@ -266,10 +272,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "Moteur de téléchargement", "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": { "options": {
"python": "Python (intégré)", "python": "Python (intégré)",
"aria2": "aria2 (expérimental)" "aria2": "aria2 (recommandé)"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -576,7 +582,13 @@
}, },
"misc": { "misc": {
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA", "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": { "metadataArchive": {
"enableArchiveDb": "Activer la base de données d'archive des métadonnées", "enableArchiveDb": "Activer la base de données d'archive des métadonnées",
@@ -640,8 +652,6 @@
}, },
"refresh": { "refresh": {
"title": "Actualiser la liste des modèles", "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", "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." "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."
}, },
@@ -682,6 +692,8 @@
"setContentRating": "Définir la classification du contenu pour tous", "setContentRating": "Définir la classification du contenu pour tous",
"copyAll": "Copier toute la syntaxe", "copyAll": "Copier toute la syntaxe",
"refreshAll": "Actualiser toutes les métadonnées", "refreshAll": "Actualiser toutes les métadonnées",
"repairMetadata": "Réparer les métadonnées de la sélection",
"reimportMetadata": "Ré-importer depuis la source",
"checkUpdates": "Vérifier les mises à jour pour la sélection", "checkUpdates": "Vérifier les mises à jour pour la sélection",
"moveAll": "Déplacer tout vers un dossier", "moveAll": "Déplacer tout vers un dossier",
"autoOrganize": "Auto-organiser la sélection", "autoOrganize": "Auto-organiser la sélection",
@@ -692,9 +704,18 @@
"unfavorite": "Retirer des favoris", "unfavorite": "Retirer des favoris",
"deleteAll": "Supprimer la sélection", "deleteAll": "Supprimer la sélection",
"downloadMissingLoras": "Télécharger les LoRAs manquants", "downloadMissingLoras": "Télécharger les LoRAs manquants",
"downloadExamples": "Télécharger les images d'exemple",
"clear": "Effacer la sélection", "clear": "Effacer la sélection",
"skipMetadataRefreshCount": "Ignorer{count} modèles", "skipMetadataRefreshCount": "Ignorer{count} modèles",
"resumeMetadataRefreshCount": "Reprendre{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": { "autoOrganizeProgress": {
"initializing": "Initialisation de l'auto-organisation...", "initializing": "Initialisation de l'auto-organisation...",
"starting": "Démarrage de l'auto-organisation pour {type}...", "starting": "Démarrage de l'auto-organisation pour {type}...",
@@ -720,6 +741,7 @@
"setContentRating": "Définir la classification du contenu", "setContentRating": "Définir la classification du contenu",
"moveToFolder": "Déplacer vers un dossier", "moveToFolder": "Déplacer vers un dossier",
"repairMetadata": "Réparer les métadonnées", "repairMetadata": "Réparer les métadonnées",
"reimportMetadata": "Ré-importer depuis la source",
"excludeModel": "Exclure le modèle", "excludeModel": "Exclure le modèle",
"restoreModel": "Restaurer le modèle", "restoreModel": "Restaurer le modèle",
"deleteModel": "Supprimer le modèle", "deleteModel": "Supprimer le modèle",
@@ -807,8 +829,6 @@
}, },
"refresh": { "refresh": {
"title": "Actualiser la liste des recipes", "title": "Actualiser la liste des recipes",
"quick": "Synchroniser les changements",
"quickTooltip": "Synchroniser les changements - actualisation rapide sans reconstruire le cache",
"full": "Reconstruire le cache", "full": "Reconstruire le cache",
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes" "fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
}, },
@@ -849,6 +869,13 @@
"skipped": "Recette déjà à la version la plus récente, aucune réparation nécessaire", "skipped": "Recette déjà à la version la plus récente, aucune réparation nécessaire",
"failed": "Échec de la réparation de la recette : {message}", "failed": "Échec de la réparation de la recette : {message}",
"missingId": "Impossible de réparer la recette : ID de recette manquant" "missingId": "Impossible de réparer la recette : ID de recette manquant"
},
"reimport": {
"starting": "Ré-import de la recette depuis la source...",
"success": "Recette ré-importée avec succès",
"noSourceUrl": "La recette n'a pas d'URL source, ré-import impossible",
"failed": "Échec du ré-import de la recette : {message}",
"missingId": "Impossible de ré-importer la recette : ID de recette manquant"
} }
}, },
"batchImport": { "batchImport": {
@@ -926,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Racine", "modelRoot": "Racine",
"moreOptions": "Plus d'options",
"collapseAll": "Réduire tous les dossiers", "collapseAll": "Réduire tous les dossiers",
"pinSidebar": "Épingler la barre latérale", "pinSidebar": "Épingler la barre latérale",
"unpinSidebar": "Désépingler la barre latérale", "unpinSidebar": "Désépingler la barre latérale",
"hideOnThisPage": "Masquer la barre latérale sur cette page",
"showSidebar": "Afficher la barre latérale",
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",
"switchToListView": "Passer en vue liste", "switchToListView": "Passer en vue liste",
"switchToTreeView": "Passer en vue arborescence", "switchToTreeView": "Passer en vue arborescence",
"recursiveOn": "Inclure les sous-dossiers", "recursiveOn": "Inclure les sous-dossiers",
@@ -948,6 +979,13 @@
"empty": { "empty": {
"noFolders": "Aucun dossier trouvé", "noFolders": "Aucun dossier trouvé",
"dragHint": "Faites glisser des éléments ici pour créer des dossiers" "dragHint": "Faites glisser des éléments ici pour créer des dossiers"
},
"folderUpdateCheck": {
"label": "Vérifier les mises à jour dans ce dossier",
"loading": "Vérification des mises à jour {type} dans ce dossier...",
"success": "{count} mise(s) à jour trouvée(s) pour les {type}s dans ce dossier",
"none": "Tous les {type}s dans ce dossier sont à jour",
"error": "Échec de la vérification des mises à jour {type} dans ce dossier : {message}"
} }
}, },
"statistics": { "statistics": {
@@ -992,9 +1030,9 @@
"download": { "download": {
"title": "Télécharger un modèle depuis une URL", "title": "Télécharger un modèle depuis une URL",
"titleWithType": "Télécharger {type} depuis une URL", "titleWithType": "Télécharger {type} depuis une URL",
"url": "URL Civitai",
"civitaiUrl": "URL Civitai :", "civitaiUrl": "URL Civitai :",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "Entrez une URL CivitAI ou CivArchive par ligne. Prend en charge plusieurs URLs pour le téléchargement par lot.",
"locationPreview": "Aperçu de l'emplacement de téléchargement", "locationPreview": "Aperçu de l'emplacement de téléchargement",
"useDefaultPath": "Utiliser le chemin par défaut", "useDefaultPath": "Utiliser le chemin par défaut",
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés", "useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
@@ -1016,6 +1054,11 @@
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.", "downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
"alreadyInLibrary": "Déjà dans la bibliothèque", "alreadyInLibrary": "Déjà dans la bibliothèque",
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]", "autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
"fileSelection": {
"title": "Choisir le format de fichier",
"files": "fichiers",
"select": "Choisir le fichier"
},
"errors": { "errors": {
"invalidUrl": "Format d'URL Civitai invalide", "invalidUrl": "Format d'URL Civitai invalide",
"noVersions": "Aucune version disponible pour ce modèle" "noVersions": "Aucune version disponible pour ce modèle"
@@ -1080,6 +1123,12 @@
"countMessage": "modèles seront définitivement supprimés.", "countMessage": "modèles seront définitivement supprimés.",
"action": "Tout supprimer" "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": { "checkUpdates": {
"title": "Vérifier les mises à jour pour tous les {typePlural} ?", "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.", "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.",
@@ -1160,6 +1209,7 @@
"editModelName": "Modifier le nom du modèle", "editModelName": "Modifier le nom du modèle",
"editFileName": "Modifier le nom de fichier", "editFileName": "Modifier le nom de fichier",
"editBaseModel": "Modifier le modèle de base", "editBaseModel": "Modifier le modèle de base",
"editVersionName": "Modifier le nom de la version",
"viewOnCivitai": "Voir sur Civitai", "viewOnCivitai": "Voir sur Civitai",
"viewOnCivitaiText": "Voir sur Civitai", "viewOnCivitaiText": "Voir sur Civitai",
"viewCreatorProfile": "Voir le profil du créateur", "viewCreatorProfile": "Voir le profil du créateur",
@@ -1191,7 +1241,9 @@
}, },
"notes": { "notes": {
"saved": "Notes sauvegardées avec succès", "saved": "Notes sauvegardées avec succès",
"saveFailed": "Échec de la sauvegarde des notes" "saveFailed": "Échec de la sauvegarde des notes",
"showMore": "Afficher plus",
"showLess": "Afficher moins"
}, },
"usageTips": { "usageTips": {
"addPresetParameter": "Ajouter un paramètre prédéfini...", "addPresetParameter": "Ajouter un paramètre prédéfini...",
@@ -1453,11 +1505,14 @@
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel", "noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
"noTargetNodeSelected": "Aucun nœud cible sélectionné", "noTargetNodeSelected": "Aucun nœud cible sélectionné",
"modelUpdated": "Modèle mis à jour dans le workflow", "modelUpdated": "Modèle mis à jour dans le workflow",
"modelFailed": "Échec de la mise à jour du nœud modèle" "modelFailed": "Échec de la mise à jour du nœud modèle",
"embeddingAdded": "Embedding ajouté au workflow",
"embeddingFailed": "Échec de l'ajout de l'embedding"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Recipe", "recipe": "Recipe",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding",
"replace": "Remplacer", "replace": "Remplacer",
"append": "Ajouter", "append": "Ajouter",
"selectTargetNode": "Sélectionner le nœud cible", "selectTargetNode": "Sélectionner le nœud cible",
@@ -1634,6 +1689,10 @@
"noRecipeId": "Aucun ID de recipe disponible", "noRecipeId": "Aucun ID de recipe disponible",
"sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}", "sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}",
"copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}", "copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}",
"createError": "Erreur lors de la création du Recipe {message}",
"createFailed": "Échec de la création du Recipe {error}",
"createMissingData": "Données requises manquantes pour créer le Recipe",
"created": "Recipe créé avec succès",
"noMissingLoras": "Aucun LoRA manquant à télécharger", "noMissingLoras": "Aucun LoRA manquant à télécharger",
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants", "missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
"preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement", "preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement",
@@ -1672,6 +1731,13 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Aucune recette sélectionnée", "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}",
"reimporting": "Ré-import de la recette depuis la source...",
"reimportSuccess": "Recette ré-importée avec succès",
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
"reimportBulkFailed": "Échec du ré-import de certaines recettes",
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées", "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." "noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
}, },
@@ -1909,9 +1975,32 @@
"warning": "Nécessite une attention", "warning": "Nécessite une attention",
"error": "Action requise" "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": { "actions": {
"runAgain": "Relancer", "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": { "toast": {
"loadFailed": "Échec du chargement des diagnostics : {message}", "loadFailed": "Échec du chargement des diagnostics : {message}",
@@ -1923,6 +2012,15 @@
"conflictsResolveFailed": "Échec de la résolution des conflits de nom de fichier : {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "Mise à jour de l'application détectée", "title": "Mise à jour de l'application détectée",

View File

@@ -16,7 +16,9 @@
"help": "עזרה", "help": "עזרה",
"add": "הוספה", "add": "הוספה",
"close": "סגור", "close": "סגור",
"menu": "תפריט" "menu": "תפריט",
"remove": "הסר",
"change": "שנה"
}, },
"status": { "status": {
"loading": "טוען...", "loading": "טוען...",
@@ -111,6 +113,7 @@
"replacePreview": "החלף תצוגה מקדימה", "replacePreview": "החלף תצוגה מקדימה",
"copyCheckpointName": "העתק שם Checkpoint", "copyCheckpointName": "העתק שם Checkpoint",
"copyEmbeddingName": "העתק שם Embedding", "copyEmbeddingName": "העתק שם Embedding",
"embeddingNameCopied": "תחביר Embedding הועתק",
"sendCheckpointToWorkflow": "שלח ל-ComfyUI", "sendCheckpointToWorkflow": "שלח ל-ComfyUI",
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI" "sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
}, },
@@ -232,7 +235,10 @@
"license": "רישיון", "license": "רישיון",
"noCreditRequired": "ללא קרדיט נדרש", "noCreditRequired": "ללא קרדיט נדרש",
"allowSellingGeneratedContent": "אפשר מכירה", "allowSellingGeneratedContent": "אפשר מכירה",
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
"noTags": "ללא תגיות", "noTags": "ללא תגיות",
"autoTags": "תגיות אוטומטיות",
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.", "noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
"clearAll": "נקה את כל המסננים", "clearAll": "נקה את כל המסננים",
"any": "כלשהו", "any": "כלשהו",
@@ -266,10 +272,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "מנגנון הורדה", "label": "מנגנון הורדה",
"help": "בחר כיצד יורדים קבצי המודל. Python משתמש במוריד המובנה. aria2 משתמש בתהליך הורדה חיצוני ניסיוני.", "help": "בחר כיצד יורדים קבצי המודל. Python משתמש במוריד המובנה. aria2 משתמש בתהליך הורדה חיצוני מומלץ.",
"options": { "options": {
"python": "Python (מובנה)", "python": "Python (מובנה)",
"aria2": "aria2 (ניסיוני)" "aria2": "aria2 (מומלץ)"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -576,7 +582,13 @@
}, },
"misc": { "misc": {
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA", "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": { "metadataArchive": {
"enableArchiveDb": "הפעל מסד נתונים של ארכיון מטא-דאטה", "enableArchiveDb": "הפעל מסד נתונים של ארכיון מטא-דאטה",
@@ -640,8 +652,6 @@
}, },
"refresh": { "refresh": {
"title": "רענן רשימת מודלים", "title": "רענן רשימת מודלים",
"quick": "סנכרון שינויים",
"quickTooltip": "סריקה לאיתור קבצי מודל חדשים או חסרים כדי לשמור את הרשימה מעודכנת.",
"full": "בניית מטמון מחדש", "full": "בניית מטמון מחדש",
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות." "fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
}, },
@@ -682,6 +692,8 @@
"setContentRating": "הגדר דירוג תוכן לכל המודלים", "setContentRating": "הגדר דירוג תוכן לכל המודלים",
"copyAll": "העתק את כל התחבירים", "copyAll": "העתק את כל התחבירים",
"refreshAll": "רענן את כל המטא-דאטה", "refreshAll": "רענן את כל המטא-דאטה",
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
"reimportMetadata": "ייבא מחדש ממקור",
"checkUpdates": "בדוק עדכונים לבחירה", "checkUpdates": "בדוק עדכונים לבחירה",
"moveAll": "העבר הכל לתיקייה", "moveAll": "העבר הכל לתיקייה",
"autoOrganize": "ארגן אוטומטית נבחרים", "autoOrganize": "ארגן אוטומטית נבחרים",
@@ -692,9 +704,18 @@
"unfavorite": "הסר ממועדפים", "unfavorite": "הסר ממועדפים",
"deleteAll": "מחק נבחרים", "deleteAll": "מחק נבחרים",
"downloadMissingLoras": "הורדת LoRAs חסרים", "downloadMissingLoras": "הורדת LoRAs חסרים",
"downloadExamples": "הורד תמונות דוגמה",
"clear": "נקה בחירה", "clear": "נקה בחירה",
"skipMetadataRefreshCount": "דילוג({count} מודלים)", "skipMetadataRefreshCount": "דילוג({count} מודלים)",
"resumeMetadataRefreshCount": "המשך({count} מודלים)", "resumeMetadataRefreshCount": "המשך({count} מודלים)",
"sendToWorkflow": "שלח ל-Workflow",
"sections": {
"workflow": "Workflow",
"metadata": "מטא-נתונים",
"attributes": "מאפיינים",
"organize": "ארגן",
"download": "הורדה"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "מאתחל ארגון אוטומטי...", "initializing": "מאתחל ארגון אוטומטי...",
"starting": "מתחיל ארגון אוטומטי עבור {type}...", "starting": "מתחיל ארגון אוטומטי עבור {type}...",
@@ -720,6 +741,7 @@
"setContentRating": "הגדר דירוג תוכן", "setContentRating": "הגדר דירוג תוכן",
"moveToFolder": "העבר לתיקייה", "moveToFolder": "העבר לתיקייה",
"repairMetadata": "תיקון מטא-דאטה", "repairMetadata": "תיקון מטא-דאטה",
"reimportMetadata": "ייבא מחדש ממקור",
"excludeModel": "החרג מודל", "excludeModel": "החרג מודל",
"restoreModel": "שחזור מודל", "restoreModel": "שחזור מודל",
"deleteModel": "מחק מודל", "deleteModel": "מחק מודל",
@@ -807,8 +829,6 @@
}, },
"refresh": { "refresh": {
"title": "רענן רשימת מתכונים", "title": "רענן רשימת מתכונים",
"quick": "סנכרן שינויים",
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
"full": "בנה מטמון מחדש", "full": "בנה מטמון מחדש",
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים" "fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
}, },
@@ -849,6 +869,13 @@
"skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון", "skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון",
"failed": "תיקון המתכון נכשל: {message}", "failed": "תיקון המתכון נכשל: {message}",
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון" "missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
},
"reimport": {
"starting": "מייבא מתכון מחדש מהמקור...",
"success": "המתכון יובא מחדש בהצלחה",
"noSourceUrl": "למתכון אין כתובת מקור, לא ניתן לייבא מחדש",
"failed": "ייבוא המתכון מחדש נכשל: {message}",
"missingId": "לא ניתן לייבא מחדש: חסר מזהה מתכון"
} }
}, },
"batchImport": { "batchImport": {
@@ -926,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "שורש", "modelRoot": "שורש",
"moreOptions": "אפשרויות נוספות",
"collapseAll": "כווץ את כל התיקיות", "collapseAll": "כווץ את כל התיקיות",
"pinSidebar": "נעל סרגל צד", "pinSidebar": "נעל סרגל צד",
"unpinSidebar": "שחרר סרגל צד", "unpinSidebar": "שחרר סרגל צד",
"hideOnThisPage": "הסתר סרגל צד בדף זה",
"showSidebar": "הצג סרגל צד",
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
"switchToListView": "עבור לתצוגת רשימה", "switchToListView": "עבור לתצוגת רשימה",
"switchToTreeView": "תצוגת עץ", "switchToTreeView": "תצוגת עץ",
"recursiveOn": "כלול תיקיות משנה", "recursiveOn": "כלול תיקיות משנה",
@@ -948,6 +979,13 @@
"empty": { "empty": {
"noFolders": "לא נמצאו תיקיות", "noFolders": "לא נמצאו תיקיות",
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות" "dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
},
"folderUpdateCheck": {
"label": "בדוק עדכונים בתיקייה זו",
"loading": "בודק עדכוני {type} בתיקייה זו...",
"success": "נמצאו {count} עדכון/ים עבור {type}s בתיקייה זו",
"none": "כל ה-{type}s בתיקייה זו מעודכנים",
"error": "נכשל בבדיקת עדכוני {type} בתיקייה: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -992,9 +1030,9 @@
"download": { "download": {
"title": "הורד מודל מכתובת URL", "title": "הורד מודל מכתובת URL",
"titleWithType": "הורד {type} מכתובת URL", "titleWithType": "הורד {type} מכתובת URL",
"url": "כתובת URL של Civitai",
"civitaiUrl": "כתובת URL של Civitai:", "civitaiUrl": "כתובת URL של Civitai:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "יש להזין כתובת URL אחת של CivitAI או CivArchive בכל שורה. תומך במספר כתובות URL להורדה בבת אחת.",
"locationPreview": "תצוגה מקדימה של מיקום ההורדה", "locationPreview": "תצוגה מקדימה של מיקום ההורדה",
"useDefaultPath": "השתמש בנתיב ברירת מחדל", "useDefaultPath": "השתמש בנתיב ברירת מחדל",
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות", "useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
@@ -1016,6 +1054,11 @@
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.", "downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
"alreadyInLibrary": "כבר בספרייה", "alreadyInLibrary": "כבר בספרייה",
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]", "autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
"fileSelection": {
"title": "בחר פורמט קובץ",
"files": "קבצים",
"select": "בחר קובץ"
},
"errors": { "errors": {
"invalidUrl": "פורמט URL של Civitai לא חוקי", "invalidUrl": "פורמט URL של Civitai לא חוקי",
"noVersions": "אין גרסאות זמינות למודל זה" "noVersions": "אין גרסאות זמינות למודל זה"
@@ -1080,6 +1123,12 @@
"countMessage": "מודלים יימחקו לצמיתות.", "countMessage": "מודלים יימחקו לצמיתות.",
"action": "מחק הכל" "action": "מחק הכל"
}, },
"bulkDeleteRecipes": {
"title": "מחק מספר מתכונים",
"message": "האם אתה בטוח שברצונך למחוק את כל המתכונים שנבחרו ואת הקבצים הנלווים אליהם?",
"countMessage": "מתכונים יימחקו לצמיתות.",
"action": "מחק הכל"
},
"checkUpdates": { "checkUpdates": {
"title": "לבדוק עדכונים לכל ה-{typePlural}?", "title": "לבדוק עדכונים לכל ה-{typePlural}?",
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.", "message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
@@ -1160,6 +1209,7 @@
"editModelName": "ערוך שם מודל", "editModelName": "ערוך שם מודל",
"editFileName": "ערוך שם קובץ", "editFileName": "ערוך שם קובץ",
"editBaseModel": "ערוך מודל בסיס", "editBaseModel": "ערוך מודל בסיס",
"editVersionName": "ערוך שם גרסה",
"viewOnCivitai": "הצג ב-Civitai", "viewOnCivitai": "הצג ב-Civitai",
"viewOnCivitaiText": "הצג ב-Civitai", "viewOnCivitaiText": "הצג ב-Civitai",
"viewCreatorProfile": "הצג פרופיל יוצר", "viewCreatorProfile": "הצג פרופיל יוצר",
@@ -1191,7 +1241,9 @@
}, },
"notes": { "notes": {
"saved": "הערות נשמרו בהצלחה", "saved": "הערות נשמרו בהצלחה",
"saveFailed": "שמירת ההערות נכשלה" "saveFailed": "שמירת ההערות נכשלה",
"showMore": "הצג עוד",
"showLess": "הצג פחות"
}, },
"usageTips": { "usageTips": {
"addPresetParameter": "הוסף פרמטר קבוע מראש...", "addPresetParameter": "הוסף פרמטר קבוע מראש...",
@@ -1453,11 +1505,14 @@
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי", "noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noTargetNodeSelected": "לא נבחר צומת יעד", "noTargetNodeSelected": "לא נבחר צומת יעד",
"modelUpdated": "מודל עודכן ב-workflow", "modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל" "modelFailed": "עדכון צומת המודל נכשל",
"embeddingAdded": "Embedding נוסף ל-workflow",
"embeddingFailed": "הוספת Embedding נכשלה"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "מתכון", "recipe": "מתכון",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding",
"replace": "החלף", "replace": "החלף",
"append": "הוסף", "append": "הוסף",
"selectTargetNode": "בחר צומת יעד", "selectTargetNode": "בחר צומת יעד",
@@ -1634,6 +1689,10 @@
"noRecipeId": "אין מזהה מתכון זמין", "noRecipeId": "אין מזהה מתכון זמין",
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}", "sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}", "copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
"createError": "שגיאה ביצירת המתכון:{message}",
"createFailed": "יצירת המתכון נכשלה:{error}",
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
"created": "המתכון נוצר בהצלחה",
"noMissingLoras": "אין LoRAs חסרים להורדה", "noMissingLoras": "אין LoRAs חסרים להורדה",
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה", "missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
"preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה", "preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה",
@@ -1672,6 +1731,13 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "לא נבחרו מתכונים", "noRecipesSelected": "לא נבחרו מתכונים",
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
"reimporting": "מייבא מתכון מחדש מהמקור...",
"reimportSuccess": "המתכון יובא מחדש בהצלחה",
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו", "noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות." "noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
}, },
@@ -1909,9 +1975,32 @@
"warning": "דורש תשומת לב", "warning": "דורש תשומת לב",
"error": "נדרשת פעולה" "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": { "actions": {
"runAgain": "הפעל שוב", "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": { "toast": {
"loadFailed": "טעינת האבחון נכשלה: {message}", "loadFailed": "טעינת האבחון נכשלה: {message}",
@@ -1923,6 +2012,15 @@
"conflictsResolveFailed": "פתרון התנגשויות שמות קבצים נכשל: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "זוהה עדכון יישום", "title": "זוהה עדכון יישום",

View File

@@ -16,7 +16,9 @@
"help": "ヘルプ", "help": "ヘルプ",
"add": "追加", "add": "追加",
"close": "閉じる", "close": "閉じる",
"menu": "メニュー" "menu": "メニュー",
"remove": "削除",
"change": "変更"
}, },
"status": { "status": {
"loading": "読み込み中...", "loading": "読み込み中...",
@@ -111,6 +113,7 @@
"replacePreview": "プレビューを置換", "replacePreview": "プレビューを置換",
"copyCheckpointName": "checkpoint名をコピー", "copyCheckpointName": "checkpoint名をコピー",
"copyEmbeddingName": "embedding名をコピー", "copyEmbeddingName": "embedding名をコピー",
"embeddingNameCopied": "Embedding構文をコピーしました",
"sendCheckpointToWorkflow": "ComfyUIに送信", "sendCheckpointToWorkflow": "ComfyUIに送信",
"sendEmbeddingToWorkflow": "ComfyUIに送信" "sendEmbeddingToWorkflow": "ComfyUIに送信"
}, },
@@ -232,7 +235,10 @@
"license": "ライセンス", "license": "ライセンス",
"noCreditRequired": "クレジット不要", "noCreditRequired": "クレジット不要",
"allowSellingGeneratedContent": "販売許可", "allowSellingGeneratedContent": "販売許可",
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
"noTags": "タグなし", "noTags": "タグなし",
"autoTags": "自動タグ",
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。", "noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
"clearAll": "すべてのフィルタをクリア", "clearAll": "すべてのフィルタをクリア",
"any": "いずれか", "any": "いずれか",
@@ -266,10 +272,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "ダウンロードバックエンド", "label": "ダウンロードバックエンド",
"help": "モデルファイルのダウンロード方法を選択します。Python は内蔵ダウンローダーを使用し、aria2 は実験的な外部ダウンローダープロセスを使用します。", "help": "モデルファイルのダウンロード方法を選択します。Python は内蔵ダウンローダーを使用し、aria2 は推奨の外部ダウンローダープロセスを使用します。",
"options": { "options": {
"python": "Python内蔵", "python": "Python内蔵",
"aria2": "aria2実験的" "aria2": "aria2推奨"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -576,7 +582,13 @@
}, },
"misc": { "misc": {
"includeTriggerWords": "LoRA構文にトリガーワードを含める", "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": { "metadataArchive": {
"enableArchiveDb": "メタデータアーカイブデータベースを有効化", "enableArchiveDb": "メタデータアーカイブデータベースを有効化",
@@ -640,8 +652,6 @@
}, },
"refresh": { "refresh": {
"title": "モデルリストを更新", "title": "モデルリストを更新",
"quick": "変更を同期",
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
"full": "キャッシュを再構築", "full": "キャッシュを再構築",
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。" "fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
}, },
@@ -682,6 +692,8 @@
"setContentRating": "すべてのモデルのコンテンツレーティングを設定", "setContentRating": "すべてのモデルのコンテンツレーティングを設定",
"copyAll": "すべての構文をコピー", "copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新", "refreshAll": "すべてのメタデータを更新",
"repairMetadata": "選択したレシピのメタデータを修復",
"reimportMetadata": "ソースから再インポート",
"checkUpdates": "選択項目の更新を確認", "checkUpdates": "選択項目の更新を確認",
"moveAll": "すべてをフォルダに移動", "moveAll": "すべてをフォルダに移動",
"autoOrganize": "自動整理を実行", "autoOrganize": "自動整理を実行",
@@ -692,9 +704,18 @@
"unfavorite": "お気に入りから削除", "unfavorite": "お気に入りから削除",
"deleteAll": "選択したものを削除", "deleteAll": "選択したものを削除",
"downloadMissingLoras": "不足している LoRA をダウンロード", "downloadMissingLoras": "不足している LoRA をダウンロード",
"downloadExamples": "例画像をダウンロード",
"clear": "選択をクリア", "clear": "選択をクリア",
"skipMetadataRefreshCount": "スキップ({count}モデル)", "skipMetadataRefreshCount": "スキップ({count}モデル)",
"resumeMetadataRefreshCount": "再開({count}モデル)", "resumeMetadataRefreshCount": "再開({count}モデル)",
"sendToWorkflow": "ワークフローに送信",
"sections": {
"workflow": "ワークフロー",
"metadata": "メタデータ",
"attributes": "属性",
"organize": "整理",
"download": "ダウンロード"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "自動整理を初期化中...", "initializing": "自動整理を初期化中...",
"starting": "{type}の自動整理を開始中...", "starting": "{type}の自動整理を開始中...",
@@ -720,6 +741,7 @@
"setContentRating": "コンテンツレーティングを設定", "setContentRating": "コンテンツレーティングを設定",
"moveToFolder": "フォルダに移動", "moveToFolder": "フォルダに移動",
"repairMetadata": "メタデータを修復", "repairMetadata": "メタデータを修復",
"reimportMetadata": "ソースから再インポート",
"excludeModel": "モデルを除外", "excludeModel": "モデルを除外",
"restoreModel": "モデルを復元", "restoreModel": "モデルを復元",
"deleteModel": "モデルを削除", "deleteModel": "モデルを削除",
@@ -807,8 +829,6 @@
}, },
"refresh": { "refresh": {
"title": "レシピリストを更新", "title": "レシピリストを更新",
"quick": "変更を同期",
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
"full": "キャッシュを再構築", "full": "キャッシュを再構築",
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン" "fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
}, },
@@ -849,6 +869,13 @@
"skipped": "レシピはすでに最新バージョンです。修復は不要です", "skipped": "レシピはすでに最新バージョンです。修復は不要です",
"failed": "レシピの修復に失敗しました: {message}", "failed": "レシピの修復に失敗しました: {message}",
"missingId": "レシピを修復できません: レシピIDがありません" "missingId": "レシピを修復できません: レシピIDがありません"
},
"reimport": {
"starting": "ソースからレシピを再インポート中...",
"success": "レシピの再インポートが完了しました",
"noSourceUrl": "レシピにソースURLがありません。再インポートできません",
"failed": "レシピの再インポートに失敗しました: {message}",
"missingId": "レシピを再インポートできません: レシピIDがありません"
} }
}, },
"batchImport": { "batchImport": {
@@ -926,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "ルート", "modelRoot": "ルート",
"moreOptions": "その他のオプション",
"collapseAll": "すべてのフォルダを折りたたむ", "collapseAll": "すべてのフォルダを折りたたむ",
"pinSidebar": "サイドバーを固定", "pinSidebar": "サイドバーを固定",
"unpinSidebar": "サイドバーの固定を解除", "unpinSidebar": "サイドバーの固定を解除",
"hideOnThisPage": "このページでサイドバーを非表示",
"showSidebar": "サイドバーを表示",
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
"switchToListView": "リストビューに切り替え", "switchToListView": "リストビューに切り替え",
"switchToTreeView": "ツリー表示に切り替え", "switchToTreeView": "ツリー表示に切り替え",
"recursiveOn": "サブフォルダーを含める", "recursiveOn": "サブフォルダーを含める",
@@ -948,6 +979,13 @@
"empty": { "empty": {
"noFolders": "フォルダが見つかりません", "noFolders": "フォルダが見つかりません",
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します" "dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
},
"folderUpdateCheck": {
"label": "このフォルダのアップデートを確認",
"loading": "このフォルダの{type}アップデートを確認中...",
"success": "このフォルダの{type}sに{count}件のアップデートが見つかりました",
"none": "このフォルダのすべての{type}sは最新です",
"error": "フォルダの{type}アップデート確認に失敗しました: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -992,9 +1030,9 @@
"download": { "download": {
"title": "URLからモデルをダウンロード", "title": "URLからモデルをダウンロード",
"titleWithType": "URLから{type}をダウンロード", "titleWithType": "URLから{type}をダウンロード",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL", "civitaiUrl": "Civitai URL",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "1行に1つのCivitAIまたはCivArchive URLを入力してください。複数のURLを一括ダウンロードできます。",
"locationPreview": "ダウンロード場所プレビュー", "locationPreview": "ダウンロード場所プレビュー",
"useDefaultPath": "デフォルトパスを使用", "useDefaultPath": "デフォルトパスを使用",
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます", "useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
@@ -1016,6 +1054,11 @@
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。", "downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
"alreadyInLibrary": "既にライブラリ内", "alreadyInLibrary": "既にライブラリ内",
"autoOrganizedPath": "[パステンプレートによる自動整理]", "autoOrganizedPath": "[パステンプレートによる自動整理]",
"fileSelection": {
"title": "ファイル形式を選択",
"files": "ファイル",
"select": "ファイルを選択"
},
"errors": { "errors": {
"invalidUrl": "無効なCivitai URL形式", "invalidUrl": "無効なCivitai URL形式",
"noVersions": "このモデルの利用可能なバージョンがありません" "noVersions": "このモデルの利用可能なバージョンがありません"
@@ -1080,6 +1123,12 @@
"countMessage": "モデルが完全に削除されます。", "countMessage": "モデルが完全に削除されます。",
"action": "すべて削除" "action": "すべて削除"
}, },
"bulkDeleteRecipes": {
"title": "複数のレシピを削除",
"message": "選択したすべてのレシピと関連ファイルを削除してもよろしいですか?",
"countMessage": "レシピが完全に削除されます。",
"action": "すべて削除"
},
"checkUpdates": { "checkUpdates": {
"title": "すべての{type}の更新を確認しますか?", "title": "すべての{type}の更新を確認しますか?",
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。", "message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
@@ -1160,6 +1209,7 @@
"editModelName": "モデル名を編集", "editModelName": "モデル名を編集",
"editFileName": "ファイル名を編集", "editFileName": "ファイル名を編集",
"editBaseModel": "ベースモデルを編集", "editBaseModel": "ベースモデルを編集",
"editVersionName": "バージョン名を編集",
"viewOnCivitai": "Civitaiで表示", "viewOnCivitai": "Civitaiで表示",
"viewOnCivitaiText": "Civitaiで表示", "viewOnCivitaiText": "Civitaiで表示",
"viewCreatorProfile": "作成者プロフィールを表示", "viewCreatorProfile": "作成者プロフィールを表示",
@@ -1191,7 +1241,9 @@
}, },
"notes": { "notes": {
"saved": "メモが正常に保存されました", "saved": "メモが正常に保存されました",
"saveFailed": "メモの保存に失敗しました" "saveFailed": "メモの保存に失敗しました",
"showMore": "もっと見る",
"showLess": "折りたたむ"
}, },
"usageTips": { "usageTips": {
"addPresetParameter": "プリセットパラメータを追加...", "addPresetParameter": "プリセットパラメータを追加...",
@@ -1453,11 +1505,14 @@
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません", "noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noTargetNodeSelected": "ターゲットノードが選択されていません", "noTargetNodeSelected": "ターゲットノードが選択されていません",
"modelUpdated": "モデルがワークフローで更新されました", "modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました" "modelFailed": "モデルノードの更新に失敗しました",
"embeddingAdded": "Embeddingをワークフローに追加しました",
"embeddingFailed": "Embeddingの追加に失敗しました"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "レシピ", "recipe": "レシピ",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding",
"replace": "置換", "replace": "置換",
"append": "追加", "append": "追加",
"selectTargetNode": "ターゲットノードを選択", "selectTargetNode": "ターゲットノードを選択",
@@ -1634,6 +1689,10 @@
"noRecipeId": "レシピIDが利用できません", "noRecipeId": "レシピIDが利用できません",
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}", "sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
"copyFailed": "レシピ構文のコピーエラー:{message}", "copyFailed": "レシピ構文のコピーエラー:{message}",
"createError": "レシピ作成中にエラーが発生しました:{message}",
"createFailed": "レシピの作成に失敗しました:{error}",
"createMissingData": "レシピ作成に必要なデータが不足しています",
"created": "レシピを作成しました",
"noMissingLoras": "ダウンロードする不足LoRAがありません", "noMissingLoras": "ダウンロードする不足LoRAがありません",
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました", "missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました", "preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
@@ -1672,6 +1731,13 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "レシピが選択されていません", "noRecipesSelected": "レシピが選択されていません",
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
"reimporting": "ソースからレシピを再インポート中...",
"reimportSuccess": "レシピの再インポートが完了しました",
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした", "noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。" "noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
}, },
@@ -1909,9 +1975,32 @@
"warning": "要注意", "warning": "要注意",
"error": "対応が必要" "error": "対応が必要"
}, },
"issues": {
"civitai_api_key": {
"title": "Civitai API キー"
},
"cache_health": {
"title": "モデルキャッシュの健全性"
},
"filename_conflicts": {
"title": "ファイル名重複競合"
},
"ui_version": {
"title": "UI バージョン"
}
},
"actions": { "actions": {
"runAgain": "再実行", "runAgain": "再実行",
"exportBundle": "パッケージをエクスポート" "exportBundle": "パッケージをエクスポート",
"open-settings": "設定を開く",
"open-settings-syntax-format": "フルパス構文に切り替え",
"repair-cache": "キャッシュを再構築",
"resolve-filename-conflicts": "競合を解決",
"reload-page": "UI をリロード"
},
"labels": {
"conflicts": "競合",
"version": "バージョン"
}, },
"toast": { "toast": {
"loadFailed": "診断の読み込みに失敗しました: {message}", "loadFailed": "診断の読み込みに失敗しました: {message}",
@@ -1923,6 +2012,15 @@
"conflictsResolveFailed": "ファイル名競合の解決に失敗しました: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "アプリケーション更新が検出されました", "title": "アプリケーション更新が検出されました",

View File

@@ -16,7 +16,9 @@
"help": "도움말", "help": "도움말",
"add": "추가", "add": "추가",
"close": "닫기", "close": "닫기",
"menu": "메뉴" "menu": "메뉴",
"remove": "제거",
"change": "변경"
}, },
"status": { "status": {
"loading": "로딩 중...", "loading": "로딩 중...",
@@ -111,6 +113,7 @@
"replacePreview": "미리보기 교체", "replacePreview": "미리보기 교체",
"copyCheckpointName": "Checkpoint 이름 복사", "copyCheckpointName": "Checkpoint 이름 복사",
"copyEmbeddingName": "Embedding 이름 복사", "copyEmbeddingName": "Embedding 이름 복사",
"embeddingNameCopied": "Embedding 구문 복사됨",
"sendCheckpointToWorkflow": "ComfyUI로 전송", "sendCheckpointToWorkflow": "ComfyUI로 전송",
"sendEmbeddingToWorkflow": "ComfyUI로 전송" "sendEmbeddingToWorkflow": "ComfyUI로 전송"
}, },
@@ -232,7 +235,10 @@
"license": "라이선스", "license": "라이선스",
"noCreditRequired": "크레딧 표기 없음", "noCreditRequired": "크레딧 표기 없음",
"allowSellingGeneratedContent": "판매 허용", "allowSellingGeneratedContent": "판매 허용",
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
"noTags": "태그 없음", "noTags": "태그 없음",
"autoTags": "자동 태그",
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.", "noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
"clearAll": "모든 필터 지우기", "clearAll": "모든 필터 지우기",
"any": "아무", "any": "아무",
@@ -266,10 +272,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "다운로드 백엔드", "label": "다운로드 백엔드",
"help": "모델 파일을 다운로드하는 방식을 선택합니다. Python은 내장 다운로더를 사용하고, aria2는 실험적인 외부 다운로더 프로세스를 사용합니다.", "help": "모델 파일을 다운로드하는 방식을 선택합니다. Python은 내장 다운로더를 사용하고, aria2는 권장되는 외부 다운로더 프로세스를 사용합니다.",
"options": { "options": {
"python": "Python(내장)", "python": "Python(내장)",
"aria2": "aria2(실험적)" "aria2": "aria2(권장)"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -576,7 +582,13 @@
}, },
"misc": { "misc": {
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함", "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": { "metadataArchive": {
"enableArchiveDb": "메타데이터 아카이브 데이터베이스 활성화", "enableArchiveDb": "메타데이터 아카이브 데이터베이스 활성화",
@@ -640,8 +652,6 @@
}, },
"refresh": { "refresh": {
"title": "모델 목록 새로고침", "title": "모델 목록 새로고침",
"quick": "변경 사항 동기화",
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
"full": "캐시 재구성", "full": "캐시 재구성",
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요." "fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
}, },
@@ -682,6 +692,8 @@
"setContentRating": "모든 모델에 콘텐츠 등급 설정", "setContentRating": "모든 모델에 콘텐츠 등급 설정",
"copyAll": "모든 문법 복사", "copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침", "refreshAll": "모든 메타데이터 새로고침",
"repairMetadata": "선택한 레시피 메타데이터 복구",
"reimportMetadata": "소스에서 다시 가져오기",
"checkUpdates": "선택 항목 업데이트 확인", "checkUpdates": "선택 항목 업데이트 확인",
"moveAll": "모두 폴더로 이동", "moveAll": "모두 폴더로 이동",
"autoOrganize": "자동 정리 선택", "autoOrganize": "자동 정리 선택",
@@ -692,9 +704,18 @@
"unfavorite": "즐겨찾기 해제", "unfavorite": "즐겨찾기 해제",
"deleteAll": "선택된 항목 삭제", "deleteAll": "선택된 항목 삭제",
"downloadMissingLoras": "누락된 LoRA 다운로드", "downloadMissingLoras": "누락된 LoRA 다운로드",
"downloadExamples": "예시 이미지 다운로드",
"clear": "선택 지우기", "clear": "선택 지우기",
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)", "skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
"resumeMetadataRefreshCount": "재개({count}개 모델)", "resumeMetadataRefreshCount": "재개({count}개 모델)",
"sendToWorkflow": "워크플로우로 보내기",
"sections": {
"workflow": "워크플로우",
"metadata": "메타데이터",
"attributes": "속성",
"organize": "정리",
"download": "다운로드"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "자동 정리 초기화 중...", "initializing": "자동 정리 초기화 중...",
"starting": "{type}에 대한 자동 정리 시작...", "starting": "{type}에 대한 자동 정리 시작...",
@@ -720,6 +741,7 @@
"setContentRating": "콘텐츠 등급 설정", "setContentRating": "콘텐츠 등급 설정",
"moveToFolder": "폴더로 이동", "moveToFolder": "폴더로 이동",
"repairMetadata": "메타데이터 복구", "repairMetadata": "메타데이터 복구",
"reimportMetadata": "소스에서 다시 가져오기",
"excludeModel": "모델 제외", "excludeModel": "모델 제외",
"restoreModel": "모델 복원", "restoreModel": "모델 복원",
"deleteModel": "모델 삭제", "deleteModel": "모델 삭제",
@@ -807,8 +829,6 @@
}, },
"refresh": { "refresh": {
"title": "레시피 목록 새로고침", "title": "레시피 목록 새로고침",
"quick": "변경 사항 동기화",
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
"full": "캐시 재구성", "full": "캐시 재구성",
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔" "fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
}, },
@@ -849,6 +869,13 @@
"skipped": "레시피가 이미 최신 버전입니다. 복구가 필요하지 않습니다", "skipped": "레시피가 이미 최신 버전입니다. 복구가 필요하지 않습니다",
"failed": "레시피 복구 실패: {message}", "failed": "레시피 복구 실패: {message}",
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락" "missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
},
"reimport": {
"starting": "소스에서 레시피를 다시 가져오는 중...",
"success": "레시피를 다시 가져왔습니다",
"noSourceUrl": "레시피에 소스 URL이 없어 다시 가져올 수 없습니다",
"failed": "레시피 다시 가져오기 실패: {message}",
"missingId": "레시피를 다시 가져올 수 없음: 레시피 ID 누락"
} }
}, },
"batchImport": { "batchImport": {
@@ -926,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "루트", "modelRoot": "루트",
"moreOptions": "더 많은 옵션",
"collapseAll": "모든 폴더 접기", "collapseAll": "모든 폴더 접기",
"pinSidebar": "사이드바 고정", "pinSidebar": "사이드바 고정",
"unpinSidebar": "사이드바 고정 해제", "unpinSidebar": "사이드바 고정 해제",
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
"showSidebar": "사이드바 표시",
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
"switchToListView": "목록 보기로 전환", "switchToListView": "목록 보기로 전환",
"switchToTreeView": "트리 보기로 전환", "switchToTreeView": "트리 보기로 전환",
"recursiveOn": "하위 폴더 포함", "recursiveOn": "하위 폴더 포함",
@@ -948,6 +979,13 @@
"empty": { "empty": {
"noFolders": "폴더를 찾을 수 없습니다", "noFolders": "폴더를 찾을 수 없습니다",
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다" "dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
},
"folderUpdateCheck": {
"label": "이 폴더의 업데이트 확인",
"loading": "이 폴더의 {type} 업데이트를 확인하는 중...",
"success": "이 폴더에서 {type}s에 대한 {count}개 업데이트를 찾았습니다",
"none": "이 폴더의 모든 {type}s가 최신 상태입니다",
"error": "폴더의 {type} 업데이트 확인 실패: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -992,9 +1030,9 @@
"download": { "download": {
"title": "URL에서 모델 다운로드", "title": "URL에서 모델 다운로드",
"titleWithType": "URL에서 {type} 다운로드", "titleWithType": "URL에서 {type} 다운로드",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL:", "civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "한 줄에 하나의 CivitAI 또는 CivArchive URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
"locationPreview": "다운로드 위치 미리보기", "locationPreview": "다운로드 위치 미리보기",
"useDefaultPath": "기본 경로 사용", "useDefaultPath": "기본 경로 사용",
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다", "useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
@@ -1016,6 +1054,11 @@
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.", "downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
"alreadyInLibrary": "이미 라이브러리에 있음", "alreadyInLibrary": "이미 라이브러리에 있음",
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]", "autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
"fileSelection": {
"title": "파일 형식 선택",
"files": "개 파일",
"select": "파일 선택"
},
"errors": { "errors": {
"invalidUrl": "잘못된 Civitai URL 형식", "invalidUrl": "잘못된 Civitai URL 형식",
"noVersions": "이 모델에 사용 가능한 버전이 없습니다" "noVersions": "이 모델에 사용 가능한 버전이 없습니다"
@@ -1080,6 +1123,12 @@
"countMessage": "개의 모델이 영구적으로 삭제됩니다.", "countMessage": "개의 모델이 영구적으로 삭제됩니다.",
"action": "모두 삭제" "action": "모두 삭제"
}, },
"bulkDeleteRecipes": {
"title": "여러 레시피 삭제",
"message": "선택된 모든 레시피와 관련 파일을 삭제하시겠습니까?",
"countMessage": "개의 레시피가 영구적으로 삭제됩니다.",
"action": "모두 삭제"
},
"checkUpdates": { "checkUpdates": {
"title": "{type} 전체 업데이트를 확인할까요?", "title": "{type} 전체 업데이트를 확인할까요?",
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.", "message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
@@ -1160,6 +1209,7 @@
"editModelName": "모델명 편집", "editModelName": "모델명 편집",
"editFileName": "파일명 편집", "editFileName": "파일명 편집",
"editBaseModel": "베이스 모델 편집", "editBaseModel": "베이스 모델 편집",
"editVersionName": "버전명 편집",
"viewOnCivitai": "Civitai에서 보기", "viewOnCivitai": "Civitai에서 보기",
"viewOnCivitaiText": "Civitai에서 보기", "viewOnCivitaiText": "Civitai에서 보기",
"viewCreatorProfile": "제작자 프로필 보기", "viewCreatorProfile": "제작자 프로필 보기",
@@ -1191,7 +1241,9 @@
}, },
"notes": { "notes": {
"saved": "메모가 성공적으로 저장됨", "saved": "메모가 성공적으로 저장됨",
"saveFailed": "메모 저장 실패" "saveFailed": "메모 저장 실패",
"showMore": "더 보기",
"showLess": "접기"
}, },
"usageTips": { "usageTips": {
"addPresetParameter": "프리셋 매개변수 추가...", "addPresetParameter": "프리셋 매개변수 추가...",
@@ -1453,11 +1505,14 @@
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다", "noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다", "noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다", "modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패" "modelFailed": "모델 노드 업데이트 실패",
"embeddingAdded": "Embedding을 워크플로에 추가했습니다",
"embeddingFailed": "Embedding 추가 실패"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "레시피", "recipe": "레시피",
"lora": "LoRA", "lora": "LoRA",
"embedding": "임베딩",
"replace": "교체", "replace": "교체",
"append": "추가", "append": "추가",
"selectTargetNode": "대상 노드 선택", "selectTargetNode": "대상 노드 선택",
@@ -1634,6 +1689,10 @@
"noRecipeId": "사용 가능한 레시피 ID가 없습니다", "noRecipeId": "사용 가능한 레시피 ID가 없습니다",
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}", "sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
"copyFailed": "레시피 문법 복사 오류: {message}", "copyFailed": "레시피 문법 복사 오류: {message}",
"createError": "레시피 생성 중 오류 발생:{message}",
"createFailed": "레시피 생성 실패:{error}",
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
"created": "레시피가 생성되었습니다",
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다", "noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다", "missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류", "preparingForDownloadFailed": "LoRA 다운로드 준비 오류",
@@ -1672,6 +1731,13 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "선택한 레시피가 없습니다", "noRecipesSelected": "선택한 레시피가 없습니다",
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
"reimportSuccess": "레시피를 다시 가져왔습니다",
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다", "noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요." "noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
}, },
@@ -1909,9 +1975,32 @@
"warning": "주의 필요", "warning": "주의 필요",
"error": "조치 필요" "error": "조치 필요"
}, },
"issues": {
"civitai_api_key": {
"title": "Civitai API 키"
},
"cache_health": {
"title": "모델 캐시 상태"
},
"filename_conflicts": {
"title": "파일명 중복 충돌"
},
"ui_version": {
"title": "UI 버전"
}
},
"actions": { "actions": {
"runAgain": "다시 실행", "runAgain": "다시 실행",
"exportBundle": "번들 내보내기" "exportBundle": "번들 내보내기",
"open-settings": "설정 열기",
"open-settings-syntax-format": "전체 경로 구문으로 전환",
"repair-cache": "캐시 재구축",
"resolve-filename-conflicts": "충돌 해결",
"reload-page": "UI 새로고침"
},
"labels": {
"conflicts": "충돌",
"version": "버전"
}, },
"toast": { "toast": {
"loadFailed": "진단 로드 실패: {message}", "loadFailed": "진단 로드 실패: {message}",
@@ -1923,6 +2012,15 @@
"conflictsResolveFailed": "파일명 충돌 해결 실패: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "애플리케이션 업데이트 감지", "title": "애플리케이션 업데이트 감지",

View File

@@ -16,7 +16,9 @@
"help": "Справка", "help": "Справка",
"add": "Добавить", "add": "Добавить",
"close": "Закрыть", "close": "Закрыть",
"menu": "Меню" "menu": "Меню",
"remove": "Удалить",
"change": "Изменить"
}, },
"status": { "status": {
"loading": "Загрузка...", "loading": "Загрузка...",
@@ -111,6 +113,7 @@
"replacePreview": "Заменить превью", "replacePreview": "Заменить превью",
"copyCheckpointName": "Копировать имя checkpoint", "copyCheckpointName": "Копировать имя checkpoint",
"copyEmbeddingName": "Копировать имя embedding", "copyEmbeddingName": "Копировать имя embedding",
"embeddingNameCopied": "Синтаксис embedding скопирован",
"sendCheckpointToWorkflow": "Отправить в ComfyUI", "sendCheckpointToWorkflow": "Отправить в ComfyUI",
"sendEmbeddingToWorkflow": "Отправить в ComfyUI" "sendEmbeddingToWorkflow": "Отправить в ComfyUI"
}, },
@@ -232,7 +235,10 @@
"license": "Лицензия", "license": "Лицензия",
"noCreditRequired": "Без указания авторства", "noCreditRequired": "Без указания авторства",
"allowSellingGeneratedContent": "Продажа разрешена", "allowSellingGeneratedContent": "Продажа разрешена",
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
"noCreditRequiredTooltip": "Использование модели без указания автора",
"noTags": "Без тегов", "noTags": "Без тегов",
"autoTags": "Авто-теги",
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.", "noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
"clearAll": "Очистить все фильтры", "clearAll": "Очистить все фильтры",
"any": "Любой", "any": "Любой",
@@ -266,10 +272,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "Бэкенд загрузки", "label": "Бэкенд загрузки",
"help": "Выберите способ загрузки файлов моделей. Python использует встроенный загрузчик. aria2 использует экспериментальный внешний процесс загрузки.", "help": "Выберите способ загрузки файлов моделей. Python использует встроенный загрузчик. aria2 использует рекомендуемый внешний процесс загрузки.",
"options": { "options": {
"python": "Python (встроенный)", "python": "Python (встроенный)",
"aria2": "aria2 (экспериментальный)" "aria2": "aria2 (рекомендуемый)"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -576,7 +582,13 @@
}, },
"misc": { "misc": {
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA", "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": { "metadataArchive": {
"enableArchiveDb": "Включить архив метаданных", "enableArchiveDb": "Включить архив метаданных",
@@ -640,8 +652,6 @@
}, },
"refresh": { "refresh": {
"title": "Обновить список моделей", "title": "Обновить список моделей",
"quick": "Синхронизировать изменения",
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
"full": "Перестроить кэш", "full": "Перестроить кэш",
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок." "fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
}, },
@@ -682,6 +692,8 @@
"setContentRating": "Установить рейтинг контента для всех", "setContentRating": "Установить рейтинг контента для всех",
"copyAll": "Копировать весь синтаксис", "copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные", "refreshAll": "Обновить все метаданные",
"repairMetadata": "Восстановить метаданные для выбранных",
"reimportMetadata": "Переимпортировать из источника",
"checkUpdates": "Проверить обновления для выбранных", "checkUpdates": "Проверить обновления для выбранных",
"moveAll": "Переместить все в папку", "moveAll": "Переместить все в папку",
"autoOrganize": "Автоматически организовать выбранные", "autoOrganize": "Автоматически организовать выбранные",
@@ -692,9 +704,18 @@
"unfavorite": "Удалить из избранного", "unfavorite": "Удалить из избранного",
"deleteAll": "Удалить выбранные", "deleteAll": "Удалить выбранные",
"downloadMissingLoras": "Скачать отсутствующие LoRAs", "downloadMissingLoras": "Скачать отсутствующие LoRAs",
"downloadExamples": "Загрузить примеры изображений",
"clear": "Очистить выбор", "clear": "Очистить выбор",
"skipMetadataRefreshCount": "Пропустить({count} моделей)", "skipMetadataRefreshCount": "Пропустить({count} моделей)",
"resumeMetadataRefreshCount": "Возобновить({count} моделей)", "resumeMetadataRefreshCount": "Возобновить({count} моделей)",
"sendToWorkflow": "Отправить в Workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Метаданные",
"attributes": "Атрибуты",
"organize": "Организовать",
"download": "Скачать"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "Инициализация автоматической организации...", "initializing": "Инициализация автоматической организации...",
"starting": "Запуск автоматической организации для {type}...", "starting": "Запуск автоматической организации для {type}...",
@@ -720,6 +741,7 @@
"setContentRating": "Установить рейтинг контента", "setContentRating": "Установить рейтинг контента",
"moveToFolder": "Переместить в папку", "moveToFolder": "Переместить в папку",
"repairMetadata": "Восстановить метаданные", "repairMetadata": "Восстановить метаданные",
"reimportMetadata": "Переимпортировать из источника",
"excludeModel": "Исключить модель", "excludeModel": "Исключить модель",
"restoreModel": "Восстановить модель", "restoreModel": "Восстановить модель",
"deleteModel": "Удалить модель", "deleteModel": "Удалить модель",
@@ -807,8 +829,6 @@
}, },
"refresh": { "refresh": {
"title": "Обновить список рецептов", "title": "Обновить список рецептов",
"quick": "Синхронизировать изменения",
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
"full": "Перестроить кэш", "full": "Перестроить кэш",
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов" "fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
}, },
@@ -849,6 +869,13 @@
"skipped": "Рецепт уже последней версии, восстановление не требуется", "skipped": "Рецепт уже последней версии, восстановление не требуется",
"failed": "Не удалось восстановить рецепт: {message}", "failed": "Не удалось восстановить рецепт: {message}",
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта" "missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
},
"reimport": {
"starting": "Переимпорт рецепта из источника...",
"success": "Рецепт успешно переимпортирован",
"noSourceUrl": "У рецепта нет URL источника, переимпорт невозможен",
"failed": "Не удалось переимпортировать рецепт: {message}",
"missingId": "Невозможно переимпортировать рецепт: отсутствует ID"
} }
}, },
"batchImport": { "batchImport": {
@@ -926,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Корень", "modelRoot": "Корень",
"moreOptions": "Дополнительные параметры",
"collapseAll": "Свернуть все папки", "collapseAll": "Свернуть все папки",
"pinSidebar": "Закрепить боковую панель", "pinSidebar": "Закрепить боковую панель",
"unpinSidebar": "Открепить боковую панель", "unpinSidebar": "Открепить боковую панель",
"hideOnThisPage": "Скрыть боковую панель на этой странице",
"showSidebar": "Показать боковую панель",
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
"switchToListView": "Переключить на вид списка", "switchToListView": "Переключить на вид списка",
"switchToTreeView": "Переключить на древовидный вид", "switchToTreeView": "Переключить на древовидный вид",
"recursiveOn": "Включать вложенные папки", "recursiveOn": "Включать вложенные папки",
@@ -948,6 +979,13 @@
"empty": { "empty": {
"noFolders": "Папки не найдены", "noFolders": "Папки не найдены",
"dragHint": "Перетащите элементы сюда, чтобы создать папки" "dragHint": "Перетащите элементы сюда, чтобы создать папки"
},
"folderUpdateCheck": {
"label": "Проверить обновления в этой папке",
"loading": "Проверка обновлений {type} в этой папке...",
"success": "Найдено {count} обновление(й) для {type}s в этой папке",
"none": "Все {type}s в этой папке актуальны",
"error": "Не удалось проверить папку на наличие обновлений {type}: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -992,9 +1030,9 @@
"download": { "download": {
"title": "Скачать модель по URL", "title": "Скачать модель по URL",
"titleWithType": "Скачать {type} по URL", "titleWithType": "Скачать {type} по URL",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL:", "civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "Введите один URL CivitAI или CivArchive в каждой строке. Поддерживается пакетная загрузка нескольких URL.",
"locationPreview": "Предпросмотр места загрузки", "locationPreview": "Предпросмотр места загрузки",
"useDefaultPath": "Использовать путь по умолчанию", "useDefaultPath": "Использовать путь по умолчанию",
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей", "useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
@@ -1016,6 +1054,11 @@
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.", "downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
"alreadyInLibrary": "Уже в библиотеке", "alreadyInLibrary": "Уже в библиотеке",
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]", "autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
"fileSelection": {
"title": "Выбрать формат файла",
"files": "файлов",
"select": "Выбрать файл"
},
"errors": { "errors": {
"invalidUrl": "Неверный формат URL Civitai", "invalidUrl": "Неверный формат URL Civitai",
"noVersions": "Нет доступных версий для этой модели" "noVersions": "Нет доступных версий для этой модели"
@@ -1080,6 +1123,12 @@
"countMessage": "моделей будут удалены навсегда.", "countMessage": "моделей будут удалены навсегда.",
"action": "Удалить все" "action": "Удалить все"
}, },
"bulkDeleteRecipes": {
"title": "Удалить несколько рецептов",
"message": "Вы уверены, что хотите удалить все выбранные рецепты и связанные с ними файлы?",
"countMessage": "рецептов будут удалены навсегда.",
"action": "Удалить все"
},
"checkUpdates": { "checkUpdates": {
"title": "Проверить обновления для всех {typePlural}?", "title": "Проверить обновления для всех {typePlural}?",
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.", "message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
@@ -1160,6 +1209,7 @@
"editModelName": "Редактировать название модели", "editModelName": "Редактировать название модели",
"editFileName": "Редактировать имя файла", "editFileName": "Редактировать имя файла",
"editBaseModel": "Редактировать базовую модель", "editBaseModel": "Редактировать базовую модель",
"editVersionName": "Редактировать название версии",
"viewOnCivitai": "Посмотреть на Civitai", "viewOnCivitai": "Посмотреть на Civitai",
"viewOnCivitaiText": "Посмотреть на Civitai", "viewOnCivitaiText": "Посмотреть на Civitai",
"viewCreatorProfile": "Посмотреть профиль создателя", "viewCreatorProfile": "Посмотреть профиль создателя",
@@ -1191,7 +1241,9 @@
}, },
"notes": { "notes": {
"saved": "Заметки успешно сохранены", "saved": "Заметки успешно сохранены",
"saveFailed": "Не удалось сохранить заметки" "saveFailed": "Не удалось сохранить заметки",
"showMore": "Показать больше",
"showLess": "Свернуть"
}, },
"usageTips": { "usageTips": {
"addPresetParameter": "Добавить предустановленный параметр...", "addPresetParameter": "Добавить предустановленный параметр...",
@@ -1453,11 +1505,14 @@
"noMatchingNodes": "В текущем workflow нет совместимых узлов", "noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noTargetNodeSelected": "Целевой узел не выбран", "noTargetNodeSelected": "Целевой узел не выбран",
"modelUpdated": "Модель обновлена в workflow", "modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели" "modelFailed": "Не удалось обновить узел модели",
"embeddingAdded": "Embedding добавлен в workflow",
"embeddingFailed": "Не удалось добавить embedding"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Рецепт", "recipe": "Рецепт",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Эмбеддинг",
"replace": "Заменить", "replace": "Заменить",
"append": "Добавить", "append": "Добавить",
"selectTargetNode": "Выберите целевой узел", "selectTargetNode": "Выберите целевой узел",
@@ -1634,6 +1689,10 @@
"noRecipeId": "ID рецепта недоступен", "noRecipeId": "ID рецепта недоступен",
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}", "sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}", "copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
"createError": "Ошибка при создании рецепта:{message}",
"createFailed": "Не удалось создать рецепт:{error}",
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
"created": "Рецепт успешно создан",
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки", "noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs", "missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
"preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки", "preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки",
@@ -1672,6 +1731,13 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Рецепты не выбраны", "noRecipesSelected": "Рецепты не выбраны",
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
"reimporting": "Переимпорт рецепта из источника...",
"reimportSuccess": "Рецепт успешно переимпортирован",
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs", "noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках." "noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
}, },
@@ -1909,9 +1975,32 @@
"warning": "Требует внимания", "warning": "Требует внимания",
"error": "Требуется действие" "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": { "actions": {
"runAgain": "Запустить снова", "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": { "toast": {
"loadFailed": "Не удалось загрузить диагностику: {message}", "loadFailed": "Не удалось загрузить диагностику: {message}",
@@ -1923,6 +2012,15 @@
"conflictsResolveFailed": "Не удалось разрешить конфликты имён файлов: {message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "Обнаружено обновление приложения", "title": "Обнаружено обновление приложения",

View File

@@ -16,7 +16,9 @@
"help": "帮助", "help": "帮助",
"add": "添加", "add": "添加",
"close": "关闭", "close": "关闭",
"menu": "菜单" "menu": "菜单",
"remove": "移除",
"change": "更换"
}, },
"status": { "status": {
"loading": "加载中...", "loading": "加载中...",
@@ -111,6 +113,7 @@
"replacePreview": "替换预览", "replacePreview": "替换预览",
"copyCheckpointName": "复制 Checkpoint 名称", "copyCheckpointName": "复制 Checkpoint 名称",
"copyEmbeddingName": "复制 Embedding 名称", "copyEmbeddingName": "复制 Embedding 名称",
"embeddingNameCopied": "已复制 Embedding 语法",
"sendCheckpointToWorkflow": "发送到 ComfyUI", "sendCheckpointToWorkflow": "发送到 ComfyUI",
"sendEmbeddingToWorkflow": "发送到 ComfyUI" "sendEmbeddingToWorkflow": "发送到 ComfyUI"
}, },
@@ -232,7 +235,10 @@
"license": "许可证", "license": "许可证",
"noCreditRequired": "无需署名", "noCreditRequired": "无需署名",
"allowSellingGeneratedContent": "允许销售", "allowSellingGeneratedContent": "允许销售",
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
"noTags": "无标签", "noTags": "无标签",
"autoTags": "自动标签",
"noBaseModelMatches": "没有基础模型符合当前搜索。", "noBaseModelMatches": "没有基础模型符合当前搜索。",
"clearAll": "清除所有筛选", "clearAll": "清除所有筛选",
"any": "任一", "any": "任一",
@@ -266,10 +272,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "下载后端", "label": "下载后端",
"help": "选择模型文件的下载方式。Python 使用内置下载器。aria2 使用实验性的外部下载进程。", "help": "选择模型文件的下载方式。Python 使用内置下载器。aria2 使用推荐的外部下载进程。",
"options": { "options": {
"python": "Python内置", "python": "Python内置",
"aria2": "aria2实验性" "aria2": "aria2推荐"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -576,7 +582,13 @@
}, },
"misc": { "misc": {
"includeTriggerWords": "复制 LoRA 语法时包含触发词", "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": { "metadataArchive": {
"enableArchiveDb": "启用元数据归档数据库", "enableArchiveDb": "启用元数据归档数据库",
@@ -640,8 +652,6 @@
}, },
"refresh": { "refresh": {
"title": "刷新模型列表", "title": "刷新模型列表",
"quick": "同步变更",
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
"full": "重建缓存", "full": "重建缓存",
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。" "fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
}, },
@@ -682,6 +692,8 @@
"setContentRating": "为所选中设置内容评级", "setContentRating": "为所选中设置内容评级",
"copyAll": "复制所选中语法", "copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据", "refreshAll": "刷新所选中元数据",
"repairMetadata": "修复所选中元数据",
"reimportMetadata": "从源重新导入",
"checkUpdates": "检查所选更新", "checkUpdates": "检查所选更新",
"moveAll": "移动所选中到文件夹", "moveAll": "移动所选中到文件夹",
"autoOrganize": "自动整理所选模型", "autoOrganize": "自动整理所选模型",
@@ -692,9 +704,18 @@
"unfavorite": "取消收藏", "unfavorite": "取消收藏",
"deleteAll": "删除已选", "deleteAll": "删除已选",
"downloadMissingLoras": "下载缺失的 LoRAs", "downloadMissingLoras": "下载缺失的 LoRAs",
"downloadExamples": "下载示例图片",
"clear": "清除选择", "clear": "清除选择",
"skipMetadataRefreshCount": "跳过({count} 个模型)", "skipMetadataRefreshCount": "跳过({count} 个模型)",
"resumeMetadataRefreshCount": "恢复({count} 个模型)", "resumeMetadataRefreshCount": "恢复({count} 个模型)",
"sendToWorkflow": "发送到工作流",
"sections": {
"workflow": "工作流",
"metadata": "元数据",
"attributes": "属性",
"organize": "整理",
"download": "下载"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "正在初始化自动整理...", "initializing": "正在初始化自动整理...",
"starting": "正在为 {type} 启动自动整理...", "starting": "正在为 {type} 启动自动整理...",
@@ -720,6 +741,7 @@
"setContentRating": "设置内容评级", "setContentRating": "设置内容评级",
"moveToFolder": "移动到文件夹", "moveToFolder": "移动到文件夹",
"repairMetadata": "修复元数据", "repairMetadata": "修复元数据",
"reimportMetadata": "从源重新导入",
"excludeModel": "排除模型", "excludeModel": "排除模型",
"restoreModel": "恢复模型", "restoreModel": "恢复模型",
"deleteModel": "删除模型", "deleteModel": "删除模型",
@@ -807,8 +829,6 @@
}, },
"refresh": { "refresh": {
"title": "刷新配方列表", "title": "刷新配方列表",
"quick": "同步变更",
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
"full": "重建缓存", "full": "重建缓存",
"fullTooltip": "重建缓存 - 重新扫描所有配方文件" "fullTooltip": "重建缓存 - 重新扫描所有配方文件"
}, },
@@ -849,6 +869,13 @@
"skipped": "配方已是最新版本,无需修复", "skipped": "配方已是最新版本,无需修复",
"failed": "修复配方失败:{message}", "failed": "修复配方失败:{message}",
"missingId": "无法修复配方:缺少配方 ID" "missingId": "无法修复配方:缺少配方 ID"
},
"reimport": {
"starting": "正在从源重新导入配方...",
"success": "配方已从源重新导入成功",
"noSourceUrl": "配方没有源URL无法重新导入",
"failed": "重新导入配方失败:{message}",
"missingId": "无法重新导入配方缺少配方ID"
} }
}, },
"batchImport": { "batchImport": {
@@ -926,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "根目录", "modelRoot": "根目录",
"moreOptions": "更多选项",
"collapseAll": "折叠所有文件夹", "collapseAll": "折叠所有文件夹",
"pinSidebar": "固定侧边栏", "pinSidebar": "固定侧边栏",
"unpinSidebar": "取消固定侧边栏", "unpinSidebar": "取消固定侧边栏",
"hideOnThisPage": "隐藏此页面侧边栏",
"showSidebar": "显示侧边栏",
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
"switchToListView": "切换到列表视图", "switchToListView": "切换到列表视图",
"switchToTreeView": "切换到树状视图", "switchToTreeView": "切换到树状视图",
"recursiveOn": "包含子文件夹", "recursiveOn": "包含子文件夹",
@@ -948,6 +979,13 @@
"empty": { "empty": {
"noFolders": "未找到文件夹", "noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹" "dragHint": "拖拽项目到此处以创建文件夹"
},
"folderUpdateCheck": {
"label": "检查此文件夹的更新",
"loading": "正在检查此文件夹中的{type}更新...",
"success": "在此文件夹中找到 {count} 个{type}更新",
"none": "此文件夹中的所有{type}都是最新版本",
"error": "检查文件夹{type}更新失败: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -992,9 +1030,9 @@
"download": { "download": {
"title": "从 URL 下载模型", "title": "从 URL 下载模型",
"titleWithType": "从 URL 下载 {type}", "titleWithType": "从 URL 下载 {type}",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL:", "civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "每行输入一个 CivitAI 或 CivArchive URL。支持批量下载多个 URL。",
"locationPreview": "下载位置预览", "locationPreview": "下载位置预览",
"useDefaultPath": "使用默认路径", "useDefaultPath": "使用默认路径",
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理", "useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
@@ -1016,6 +1054,11 @@
"downloadedTooltip": "之前已下载,但当前不在你的库中。", "downloadedTooltip": "之前已下载,但当前不在你的库中。",
"alreadyInLibrary": "已存在于库中", "alreadyInLibrary": "已存在于库中",
"autoOrganizedPath": "【已按路径模板自动整理】", "autoOrganizedPath": "【已按路径模板自动整理】",
"fileSelection": {
"title": "选择文件格式",
"files": "个文件",
"select": "选择文件"
},
"errors": { "errors": {
"invalidUrl": "无效的 Civitai URL 格式", "invalidUrl": "无效的 Civitai URL 格式",
"noVersions": "此模型没有可用版本" "noVersions": "此模型没有可用版本"
@@ -1080,6 +1123,12 @@
"countMessage": "模型将被永久删除。", "countMessage": "模型将被永久删除。",
"action": "全部删除" "action": "全部删除"
}, },
"bulkDeleteRecipes": {
"title": "删除多个配方",
"message": "你确定要删除所有选中的配方及其相关文件吗?",
"countMessage": "配方将被永久删除。",
"action": "全部删除"
},
"checkUpdates": { "checkUpdates": {
"title": "检查所有 {type} 的更新?", "title": "检查所有 {type} 的更新?",
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。", "message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
@@ -1160,6 +1209,7 @@
"editModelName": "编辑模型名称", "editModelName": "编辑模型名称",
"editFileName": "编辑文件名", "editFileName": "编辑文件名",
"editBaseModel": "编辑基础模型", "editBaseModel": "编辑基础模型",
"editVersionName": "编辑版本名称",
"viewOnCivitai": "在 Civitai 查看", "viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看", "viewOnCivitaiText": "在 Civitai 查看",
"viewCreatorProfile": "查看创作者主页", "viewCreatorProfile": "查看创作者主页",
@@ -1191,7 +1241,9 @@
}, },
"notes": { "notes": {
"saved": "备注保存成功", "saved": "备注保存成功",
"saveFailed": "备注保存失败" "saveFailed": "备注保存失败",
"showMore": "展开",
"showLess": "收起"
}, },
"usageTips": { "usageTips": {
"addPresetParameter": "添加预设参数...", "addPresetParameter": "添加预设参数...",
@@ -1453,11 +1505,14 @@
"noMatchingNodes": "当前工作流中没有兼容的节点", "noMatchingNodes": "当前工作流中没有兼容的节点",
"noTargetNodeSelected": "未选择目标节点", "noTargetNodeSelected": "未选择目标节点",
"modelUpdated": "模型已更新到工作流", "modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型节点失败" "modelFailed": "更新模型节点失败",
"embeddingAdded": "Embedding 已追加到工作流",
"embeddingFailed": "添加 Embedding 失败"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "配方", "recipe": "配方",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding",
"replace": "替换", "replace": "替换",
"append": "追加", "append": "追加",
"selectTargetNode": "选择目标节点", "selectTargetNode": "选择目标节点",
@@ -1634,6 +1689,10 @@
"noRecipeId": "无配方 ID", "noRecipeId": "无配方 ID",
"sendToWorkflowFailed": "发送配方到工作流失败:{message}", "sendToWorkflowFailed": "发送配方到工作流失败:{message}",
"copyFailed": "复制配方语法出错:{message}", "copyFailed": "复制配方语法出错:{message}",
"createError": "创建配方时出错:{message}",
"createFailed": "创建配方失败:{error}",
"createMissingData": "缺少创建配方所需的数据",
"created": "配方创建成功",
"noMissingLoras": "没有缺失的 LoRA 可下载", "noMissingLoras": "没有缺失的 LoRA 可下载",
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败", "missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
"preparingForDownloadFailed": "准备下载 LoRA 时出错", "preparingForDownloadFailed": "准备下载 LoRA 时出错",
@@ -1672,6 +1731,13 @@
"batchImportBrowseFailed": "浏览目录失败:{message}", "batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "已选择目录:{path}", "batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方", "noRecipesSelected": "未选择任何配方",
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
"repairBulkSkipped": "所选 {total} 个配方无需修复",
"repairBulkFailed": "修复所选配方失败:{message}",
"reimporting": "正在从源重新导入配方...",
"reimportSuccess": "配方已从源重新导入成功",
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
"reimportBulkFailed": "重新导入某些配方失败",
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs", "noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。" "noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
}, },
@@ -1909,9 +1975,32 @@
"warning": "需要关注", "warning": "需要关注",
"error": "需要处理" "error": "需要处理"
}, },
"issues": {
"civitai_api_key": {
"title": "Civitai API 密钥"
},
"cache_health": {
"title": "模型缓存健康状态"
},
"filename_conflicts": {
"title": "文件名重复冲突"
},
"ui_version": {
"title": "UI 版本"
}
},
"actions": { "actions": {
"runAgain": "重新检查", "runAgain": "重新检查",
"exportBundle": "导出诊断包" "exportBundle": "导出诊断包",
"open-settings": "打开设置",
"open-settings-syntax-format": "切换为完整路径语法",
"repair-cache": "重建缓存",
"resolve-filename-conflicts": "解决冲突",
"reload-page": "刷新 UI"
},
"labels": {
"conflicts": "冲突详情",
"version": "版本信息"
}, },
"toast": { "toast": {
"loadFailed": "加载诊断结果失败:{message}", "loadFailed": "加载诊断结果失败:{message}",
@@ -1923,6 +2012,15 @@
"conflictsResolveFailed": "解决文件名冲突失败:{message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "检测到应用更新", "title": "检测到应用更新",

View File

@@ -16,7 +16,9 @@
"help": "說明", "help": "說明",
"add": "新增", "add": "新增",
"close": "關閉", "close": "關閉",
"menu": "選單" "menu": "選單",
"remove": "移除",
"change": "更換"
}, },
"status": { "status": {
"loading": "載入中...", "loading": "載入中...",
@@ -111,6 +113,7 @@
"replacePreview": "更換預覽圖", "replacePreview": "更換預覽圖",
"copyCheckpointName": "複製檢查點名稱", "copyCheckpointName": "複製檢查點名稱",
"copyEmbeddingName": "複製嵌入名稱", "copyEmbeddingName": "複製嵌入名稱",
"embeddingNameCopied": "已複製 Embedding 語法",
"sendCheckpointToWorkflow": "傳送到 ComfyUI", "sendCheckpointToWorkflow": "傳送到 ComfyUI",
"sendEmbeddingToWorkflow": "傳送到 ComfyUI" "sendEmbeddingToWorkflow": "傳送到 ComfyUI"
}, },
@@ -232,7 +235,10 @@
"license": "授權", "license": "授權",
"noCreditRequired": "無需署名", "noCreditRequired": "無需署名",
"allowSellingGeneratedContent": "允許銷售", "allowSellingGeneratedContent": "允許銷售",
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
"noTags": "無標籤", "noTags": "無標籤",
"autoTags": "自動標籤",
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。", "noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
"clearAll": "清除所有篩選", "clearAll": "清除所有篩選",
"any": "任一", "any": "任一",
@@ -266,10 +272,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "下載後端", "label": "下載後端",
"help": "選擇模型檔案的下載方式。Python 使用內建下載器。aria2 使用實驗性的外部下載程序。", "help": "選擇模型檔案的下載方式。Python 使用內建下載器。aria2 使用推薦的外部下載程序。",
"options": { "options": {
"python": "Python內建", "python": "Python內建",
"aria2": "aria2實驗性" "aria2": "aria2推薦"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -576,7 +582,13 @@
}, },
"misc": { "misc": {
"includeTriggerWords": "在 LoRA 語法中包含觸發詞", "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": { "metadataArchive": {
"enableArchiveDb": "啟用中繼資料封存資料庫", "enableArchiveDb": "啟用中繼資料封存資料庫",
@@ -640,8 +652,6 @@
}, },
"refresh": { "refresh": {
"title": "重新整理模型列表", "title": "重新整理模型列表",
"quick": "同步變更",
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
"full": "重建快取", "full": "重建快取",
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。" "fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
}, },
@@ -682,6 +692,8 @@
"setContentRating": "為全部設定內容分級", "setContentRating": "為全部設定內容分級",
"copyAll": "複製全部語法", "copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata", "refreshAll": "刷新全部 metadata",
"repairMetadata": "修復所選中元數據",
"reimportMetadata": "從來源重新匯入",
"checkUpdates": "檢查所選更新", "checkUpdates": "檢查所選更新",
"moveAll": "全部移動到資料夾", "moveAll": "全部移動到資料夾",
"autoOrganize": "自動整理所選模型", "autoOrganize": "自動整理所選模型",
@@ -692,9 +704,18 @@
"unfavorite": "取消收藏", "unfavorite": "取消收藏",
"deleteAll": "刪除所選", "deleteAll": "刪除所選",
"downloadMissingLoras": "下載缺失的 LoRAs", "downloadMissingLoras": "下載缺失的 LoRAs",
"downloadExamples": "下載範例圖片",
"clear": "清除選取", "clear": "清除選取",
"skipMetadataRefreshCount": "跳過({count} 個模型)", "skipMetadataRefreshCount": "跳過({count} 個模型)",
"resumeMetadataRefreshCount": "恢復({count} 個模型)", "resumeMetadataRefreshCount": "恢復({count} 個模型)",
"sendToWorkflow": "發送到工作流",
"sections": {
"workflow": "工作流",
"metadata": "元數據",
"attributes": "屬性",
"organize": "整理",
"download": "下載"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "正在初始化自動整理...", "initializing": "正在初始化自動整理...",
"starting": "正在開始自動整理 {type}...", "starting": "正在開始自動整理 {type}...",
@@ -720,6 +741,7 @@
"setContentRating": "設定內容分級", "setContentRating": "設定內容分級",
"moveToFolder": "移動到資料夾", "moveToFolder": "移動到資料夾",
"repairMetadata": "修復元數據", "repairMetadata": "修復元數據",
"reimportMetadata": "從來源重新匯入",
"excludeModel": "排除模型", "excludeModel": "排除模型",
"restoreModel": "還原模型", "restoreModel": "還原模型",
"deleteModel": "刪除模型", "deleteModel": "刪除模型",
@@ -807,8 +829,6 @@
}, },
"refresh": { "refresh": {
"title": "重新整理配方列表", "title": "重新整理配方列表",
"quick": "同步變更",
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
"full": "重建快取", "full": "重建快取",
"fullTooltip": "重建快取 - 重新掃描所有配方檔案" "fullTooltip": "重建快取 - 重新掃描所有配方檔案"
}, },
@@ -849,6 +869,13 @@
"skipped": "配方已是最新版本,無需修復", "skipped": "配方已是最新版本,無需修復",
"failed": "修復配方失敗:{message}", "failed": "修復配方失敗:{message}",
"missingId": "無法修復配方:缺少配方 ID" "missingId": "無法修復配方:缺少配方 ID"
},
"reimport": {
"starting": "正在從來源重新匯入配方...",
"success": "配方已從來源重新匯入成功",
"noSourceUrl": "配方沒有來源URL無法重新匯入",
"failed": "重新匯入配方失敗:{message}",
"missingId": "無法重新匯入配方缺少配方ID"
} }
}, },
"batchImport": { "batchImport": {
@@ -926,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "根目錄", "modelRoot": "根目錄",
"moreOptions": "更多選項",
"collapseAll": "全部摺疊資料夾", "collapseAll": "全部摺疊資料夾",
"pinSidebar": "固定側邊欄", "pinSidebar": "固定側邊欄",
"unpinSidebar": "取消固定側邊欄", "unpinSidebar": "取消固定側邊欄",
"hideOnThisPage": "隱藏此頁面側邊欄",
"showSidebar": "顯示側邊欄",
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
"switchToListView": "切換至列表檢視", "switchToListView": "切換至列表檢視",
"switchToTreeView": "切換到樹狀檢視", "switchToTreeView": "切換到樹狀檢視",
"recursiveOn": "包含子資料夾", "recursiveOn": "包含子資料夾",
@@ -948,6 +979,13 @@
"empty": { "empty": {
"noFolders": "未找到資料夾", "noFolders": "未找到資料夾",
"dragHint": "將項目拖到此處以建立資料夾" "dragHint": "將項目拖到此處以建立資料夾"
},
"folderUpdateCheck": {
"label": "檢查此資料夾的更新",
"loading": "正在檢查此資料夾中的{type}更新...",
"success": "在此資料夾中找到 {count} 個{type}更新",
"none": "此資料夾中的所有{type}都是最新版本",
"error": "檢查資料夾{type}更新失敗: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -992,9 +1030,9 @@
"download": { "download": {
"title": "從網址下載模型", "title": "從網址下載模型",
"titleWithType": "從網址下載 {type}", "titleWithType": "從網址下載 {type}",
"url": "Civitai 網址",
"civitaiUrl": "Civitai 網址:", "civitaiUrl": "Civitai 網址:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "每行輸入一個 CivitAI 或 CivArchive URL。支援批量下載多個 URL。",
"locationPreview": "下載位置預覽", "locationPreview": "下載位置預覽",
"useDefaultPath": "使用預設路徑", "useDefaultPath": "使用預設路徑",
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理", "useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
@@ -1016,6 +1054,11 @@
"downloadedTooltip": "先前已下載,但目前不在你的庫中。", "downloadedTooltip": "先前已下載,但目前不在你的庫中。",
"alreadyInLibrary": "已在庫存", "alreadyInLibrary": "已在庫存",
"autoOrganizedPath": "[依路徑範本自動整理]", "autoOrganizedPath": "[依路徑範本自動整理]",
"fileSelection": {
"title": "選擇檔案格式",
"files": "個檔案",
"select": "選擇檔案"
},
"errors": { "errors": {
"invalidUrl": "Civitai 網址格式無效", "invalidUrl": "Civitai 網址格式無效",
"noVersions": "此模型無可用版本" "noVersions": "此模型無可用版本"
@@ -1080,6 +1123,12 @@
"countMessage": "模型將被永久刪除。", "countMessage": "模型將被永久刪除。",
"action": "全部刪除" "action": "全部刪除"
}, },
"bulkDeleteRecipes": {
"title": "刪除多個配方",
"message": "您確定要刪除所有選取的配方及其相關檔案嗎?",
"countMessage": "配方將被永久刪除。",
"action": "全部刪除"
},
"checkUpdates": { "checkUpdates": {
"title": "要檢查所有 {type} 的更新嗎?", "title": "要檢查所有 {type} 的更新嗎?",
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。", "message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
@@ -1160,6 +1209,7 @@
"editModelName": "編輯模型名稱", "editModelName": "編輯模型名稱",
"editFileName": "編輯檔案名稱", "editFileName": "編輯檔案名稱",
"editBaseModel": "編輯基礎模型", "editBaseModel": "編輯基礎模型",
"editVersionName": "編輯版本名稱",
"viewOnCivitai": "在 Civitai 查看", "viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看", "viewOnCivitaiText": "在 Civitai 查看",
"viewCreatorProfile": "查看創作者個人檔案", "viewCreatorProfile": "查看創作者個人檔案",
@@ -1191,7 +1241,9 @@
}, },
"notes": { "notes": {
"saved": "備註已儲存", "saved": "備註已儲存",
"saveFailed": "儲存備註失敗" "saveFailed": "儲存備註失敗",
"showMore": "展開",
"showLess": "收起"
}, },
"usageTips": { "usageTips": {
"addPresetParameter": "新增預設參數...", "addPresetParameter": "新增預設參數...",
@@ -1453,11 +1505,14 @@
"noMatchingNodes": "目前工作流程中沒有相容的節點", "noMatchingNodes": "目前工作流程中沒有相容的節點",
"noTargetNodeSelected": "未選擇目標節點", "noTargetNodeSelected": "未選擇目標節點",
"modelUpdated": "模型已更新到工作流", "modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型節點失敗" "modelFailed": "更新模型節點失敗",
"embeddingAdded": "Embedding 已附加到工作流",
"embeddingFailed": "傳送 Embedding 到工作流失敗"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "配方", "recipe": "配方",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding",
"replace": "取代", "replace": "取代",
"append": "附加", "append": "附加",
"selectTargetNode": "選擇目標節點", "selectTargetNode": "選擇目標節點",
@@ -1634,6 +1689,10 @@
"noRecipeId": "無配方 ID", "noRecipeId": "無配方 ID",
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}", "sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
"copyFailed": "複製配方語法錯誤:{message}", "copyFailed": "複製配方語法錯誤:{message}",
"createError": "建立配方時發生錯誤:{message}",
"createFailed": "建立配方失敗:{error}",
"createMissingData": "缺少建立配方所需的資料",
"created": "配方建立成功",
"noMissingLoras": "無缺少的 LoRA 可下載", "noMissingLoras": "無缺少的 LoRA 可下載",
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗", "missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤", "preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
@@ -1672,6 +1731,13 @@
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}", "batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "已選擇目錄:{path}", "batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何食譜", "noRecipesSelected": "未選取任何食譜",
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
"repairBulkSkipped": "所選 {total} 個配方無需修復",
"repairBulkFailed": "修復所選配方失敗:{message}",
"reimporting": "正在從來源重新匯入配方...",
"reimportSuccess": "配方已從來源重新匯入成功",
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
"reimportBulkFailed": "重新匯入某些配方失敗",
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs", "noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。" "noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
}, },
@@ -1909,9 +1975,32 @@
"warning": "需要注意", "warning": "需要注意",
"error": "需要處理" "error": "需要處理"
}, },
"issues": {
"civitai_api_key": {
"title": "Civitai API 金鑰"
},
"cache_health": {
"title": "模型快取健康狀態"
},
"filename_conflicts": {
"title": "檔案名稱重複衝突"
},
"ui_version": {
"title": "UI 版本"
}
},
"actions": { "actions": {
"runAgain": "重新執行", "runAgain": "重新執行",
"exportBundle": "匯出套件" "exportBundle": "匯出套件",
"open-settings": "開啟設定",
"open-settings-syntax-format": "切換為完整路徑語法",
"repair-cache": "重建快取",
"resolve-filename-conflicts": "解決衝突",
"reload-page": "重新載入 UI"
},
"labels": {
"conflicts": "衝突詳情",
"version": "版本"
}, },
"toast": { "toast": {
"loadFailed": "載入診斷失敗:{message}", "loadFailed": "載入診斷失敗:{message}",
@@ -1923,6 +2012,15 @@
"conflictsResolveFailed": "解決檔案名稱衝突失敗:{message}" "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": { "banners": {
"versionMismatch": { "versionMismatch": {
"title": "偵測到應用程式更新", "title": "偵測到應用程式更新",

View File

@@ -172,6 +172,12 @@ class Config:
self.extra_unet_roots: List[str] = [] self.extra_unet_roots: List[str] = []
self.extra_embeddings_roots: List[str] = [] self.extra_embeddings_roots: List[str] = []
self.recipes_path: 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 # Scan symbolic links during initialization
self._initialize_symlink_mappings() self._initialize_symlink_mappings()
@@ -179,6 +185,96 @@ class Config:
# Save the paths to settings.json when running in ComfyUI mode # Save the paths to settings.json when running in ComfyUI mode
self.save_folder_paths_to_settings() 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): def save_folder_paths_to_settings(self):
"""Persist ComfyUI-derived folder paths to the multi-library settings.""" """Persist ComfyUI-derived folder paths to the multi-library settings."""
try: try:

View File

@@ -184,44 +184,15 @@ class LoraManager:
async def _initialize_services(cls): async def _initialize_services(cls):
"""Initialize all services using the ServiceRegistry""" """Initialize all services using the ServiceRegistry"""
try: 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 # Initialize CivitaiClient first to ensure it's ready for other services
await ServiceRegistry.get_civitai_client() await ServiceRegistry.get_civitai_client()
# Register DownloadManager with ServiceRegistry # Register DownloadManager with ServiceRegistry
await ServiceRegistry.get_download_manager() await ServiceRegistry.get_download_manager()
# Initialize DownloadQueueService for persistent queue/history
await ServiceRegistry.get_download_queue_service()
await ServiceRegistry.get_backup_service() await ServiceRegistry.get_backup_service()
from .services.metadata_service import initialize_metadata_providers from .services.metadata_service import initialize_metadata_providers

View File

@@ -5,9 +5,10 @@ MODELS = "models"
PROMPTS = "prompts" PROMPTS = "prompts"
SAMPLING = "sampling" SAMPLING = "sampling"
LORAS = "loras" LORAS = "loras"
EMBEDDINGS = "embeddings"
SIZE = "size" SIZE = "size"
IMAGES = "images" IMAGES = "images"
IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes
# Complete list of categories to track # Complete list of categories to track
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES] METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, EMBEDDINGS, SIZE, IMAGES]

View File

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

View File

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

View File

@@ -44,11 +44,29 @@ import folder_paths # type: ignore
logger = logging.getLogger(__name__) 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): def extract_lora_name(lora_path):
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')""" normalized = lora_path.replace("\\", "/")
# Get the basename without extension basename = os.path.basename(normalized)
basename = os.path.basename(lora_path) name_no_ext = os.path.splitext(basename)[0]
return 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): def get_loras_list(kwargs):

View File

@@ -7,7 +7,7 @@ import re
from typing import Dict, List, Any, Optional, Tuple from typing import Dict, List, Any, Optional, Tuple
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from ..config import config 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 from ..utils.civitai_utils import rewrite_preview_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -58,9 +58,52 @@ class RecipeMetadataParser(ABC):
civitai_info, error_msg = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None) civitai_info, error_msg = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
if not civitai_info or error_msg == "Model not found": if not civitai_info or error_msg == "Model not found":
# Model not found or deleted # CivitAI may fail to resolve a hash that is still being
lora_entry['isDeleted'] = True # computed (known CivitAI issue). Before marking as deleted,
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png' # try to reconcile with a local model that has the same
# filename and matching AutoV3 hash.
reconciled = False
file_name = lora_entry.get("file_name")
if file_name and recipe_scanner and hash_value:
lora_scanner = getattr(recipe_scanner, "_lora_scanner", None)
if lora_scanner:
try:
# Local import to avoid circular dependency:
# base.py → file_utils → settings_manager → ...
# → recipe_scanner → enrichment → base.py
from ..utils.file_utils import calculate_autov3 # fmt: skip
cache = await lora_scanner.get_cached_data()
for item in getattr(cache, "raw_data", []):
if item.get("file_name") == file_name:
local_path = item.get("file_path")
if local_path and os.path.exists(local_path):
local_autov3 = calculate_autov3(local_path)
if local_autov3 and local_autov3 == hash_value:
lora_entry["existsLocally"] = True
lora_entry["localPath"] = local_path
lora_entry["hash"] = item.get("sha256", hash_value)
if "preview_url" in item:
lora_entry["thumbnailUrl"] = config.get_preview_static_url(item["preview_url"])
civ = item.get("civitai") or {}
if isinstance(civ, dict):
if civ.get("id") is not None:
lora_entry["id"] = civ["id"]
if civ.get("modelId") is not None:
lora_entry["modelId"] = civ["modelId"]
if civ.get("name"):
lora_entry["version"] = civ["name"]
# model_name is the CivitAI model display
# name stored directly in the cache column.
cached_model_name = item.get("model_name")
if cached_model_name:
lora_entry["name"] = cached_model_name
reconciled = True
break
except Exception:
pass
if not reconciled:
lora_entry['isDeleted'] = True
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
return lora_entry return lora_entry
# Get model type and validate # Get model type and validate
@@ -173,6 +216,20 @@ class RecipeMetadataParser(ABC):
checkpoint['isDeleted'] = True checkpoint['isDeleted'] = True
return checkpoint 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']: if 'model' in civitai_data and 'name' in civitai_data['model']:
checkpoint['name'] = civitai_data['model']['name'] checkpoint['name'] = civitai_data['model']['name']

View File

@@ -16,7 +16,9 @@ class RecipeEnricher:
async def enrich_recipe( async def enrich_recipe(
recipe: Dict[str, Any], recipe: Dict[str, Any],
civitai_client: 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: ) -> bool:
""" """
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params. Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
@@ -25,6 +27,9 @@ class RecipeEnricher:
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized. recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
civitai_client: Authenticated Civitai client instance. civitai_client: Authenticated Civitai client instance.
request_params: (Optional) Parameters from a user request (e.g. import). 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: Returns:
bool: True if the recipe was modified, False otherwise. bool: True if the recipe was modified, False otherwise.
@@ -32,39 +37,44 @@ class RecipeEnricher:
updated = False updated = False
gen_params = recipe.get("gen_params", {}) gen_params = recipe.get("gen_params", {})
# 1. Fetch Civitai Info if available # 1. Obtain Civitai metadata
civitai_meta = None civitai_meta = None
model_version_id = None model_version_id = prefetched_model_version_id
source_url = recipe.get("source_url") or recipe.get("source_path", "") source_path = recipe.get("source_path", "")
# Check if it's a Civitai image URL if prefetched_civitai_meta_raw is not None:
image_id = extract_civitai_image_id(str(source_url)) raw_meta = prefetched_civitai_meta_raw
if image_id: if isinstance(raw_meta, dict):
try: if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
image_info = await civitai_client.get_image_info( civitai_meta = raw_meta["meta"]
image_id, source_url=str(source_url) else:
) civitai_meta = raw_meta
if image_info: else:
# Handle nested meta often found in Civitai API responses image_id = extract_civitai_image_id(str(source_path))
raw_meta = image_info.get("meta") if image_id:
if isinstance(raw_meta, dict): try:
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict): image_info = await civitai_client.get_image_info(
civitai_meta = raw_meta["meta"] image_id, source_url=str(source_path)
else: )
civitai_meta = raw_meta 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") model_version_id = image_info.get("modelVersionId")
except Exception as e:
logger.warning(f"Failed to fetch Civitai image info: {e}")
# If not at top level, check resources in meta if not model_version_id and civitai_meta:
if not model_version_id and civitai_meta: resources = civitai_meta.get("civitaiResources", [])
resources = civitai_meta.get("civitaiResources", []) for res in resources:
for res in resources: if res.get("type") == "checkpoint":
if res.get("type") == "checkpoint": model_version_id = res.get("modelVersionId")
model_version_id = res.get("modelVersionId") break
break
except Exception as e:
logger.warning(f"Failed to fetch Civitai image info: {e}")
# 2. Merge Parameters # 2. Merge Parameters
# Priority: request_params > civitai_meta > embedded (existing gen_params) # Priority: request_params > civitai_meta > embedded (existing gen_params)
@@ -180,26 +190,41 @@ class RecipeEnricher:
existing_cp = recipe.get("checkpoint") existing_cp = recipe.get("checkpoint")
if existing_cp is None: if existing_cp is None:
existing_cp = {} existing_cp = {}
# Extract baseModel from raw civitai_info before populate_checkpoint_from_civitai
# (populate may reject non-checkpoint types and lose this data)
base_model_from_civitai: str = ""
if isinstance(civitai_info, dict):
base_model_from_civitai = civitai_info.get("baseModel", "") or ""
elif isinstance(civitai_info, tuple) and len(civitai_info) > 0 and isinstance(civitai_info[0], dict):
base_model_from_civitai = civitai_info[0].get("baseModel", "") or ""
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info) checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
# 1. First, resolve base_model using full data before we format it away
# 1. Resolve base_model from checkpoint_data first, then fall back to raw civitai_info
current_base_model = recipe.get("base_model") current_base_model = recipe.get("base_model")
resolved_base_model = checkpoint_data.get("baseModel") resolved_base_model = checkpoint_data.get("baseModel") or base_model_from_civitai
if resolved_base_model: if resolved_base_model:
# Update if empty OR if it matches our generic prefix but is less specific
is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"] is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"]
if is_generic and resolved_base_model != current_base_model: if is_generic and resolved_base_model != current_base_model:
recipe["base_model"] = resolved_base_model recipe["base_model"] = resolved_base_model
# 2. Format according to requirements: type, modelId, modelVersionId, modelName, modelVersionName # 2. Only format and save checkpoint if it has real data (not just type after type rejection)
formatted_checkpoint = { has_checkpoint_data = any([
"type": "checkpoint", checkpoint_data.get("modelId"),
"modelId": checkpoint_data.get("modelId"), checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"), checkpoint_data.get("name"),
"modelName": checkpoint_data.get("name"), # In base.py, 'name' is populated from civitai_data['model']['name'] checkpoint_data.get("version"),
"modelVersionName": checkpoint_data.get("version") # In base.py, 'version' is populated from civitai_data['name'] ])
} if has_checkpoint_data:
# Remove None values formatted_checkpoint = {
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None} "type": "checkpoint",
"modelId": checkpoint_data.get("modelId"),
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
"modelName": checkpoint_data.get("name"),
"modelVersionName": checkpoint_data.get("version"),
}
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
return True return True
else: else:

View File

@@ -6,6 +6,7 @@ from typing import Dict, Any, Union
from ..base import RecipeMetadataParser from ..base import RecipeMetadataParser
from ..constants import GEN_PARAM_KEYS from ..constants import GEN_PARAM_KEYS
from ...services.metadata_service import get_default_metadata_provider from ...services.metadata_service import get_default_metadata_provider
from ...config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -73,7 +74,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
return False return False
async def parse_metadata( # type: ignore[override] async def parse_metadata( # type: ignore[override]
self, user_comment, recipe_scanner=None, civitai_client=None self, user_comment, recipe_scanner=None, civitai_client=None,
local_cache: dict[str, Any] | None = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Parse metadata from Civitai image format """Parse metadata from Civitai image format
@@ -81,6 +83,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
user_comment: The metadata from the image (dict) user_comment: The metadata from the image (dict)
recipe_scanner: Optional recipe scanner service recipe_scanner: Optional recipe scanner service
civitai_client: Optional Civitai API client (deprecated, use metadata_provider instead) civitai_client: Optional Civitai API client (deprecated, use metadata_provider instead)
local_cache: Optional dict mapping sha256/autov3 hash → scanner cache item.
When provided, matching models skip CivitAI API calls.
Returns: Returns:
Dict containing parsed recipe data Dict containing parsed recipe data
@@ -185,8 +189,77 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Process standard resources array # Process standard resources array
if "resources" in metadata and isinstance(metadata["resources"], list): if "resources" in metadata and isinstance(metadata["resources"], list):
for resource in metadata["resources"]: 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
cp_hash = checkpoint_entry.get("hash")
if cp_hash and metadata_provider:
local_cached = local_cache.get(cp_hash) if local_cache else None
if local_cached:
self._populate_entry_from_cache(
checkpoint_entry, local_cached
)
bm = checkpoint_entry.get("baseModel", "")
if bm and not result["base_model"]:
result["base_model"] = bm
else:
try:
civitai_info = (
await metadata_provider.get_model_by_hash(
cp_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"{cp_hash}: {e}"
)
if result["model"] is None:
result["model"] = checkpoint_entry
continue
# Modified to process resources without a type field as potential LoRAs # 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", "") lora_hash = resource.get("hash", "")
# Try to get hash from the hashes field if not present in resource # Try to get hash from the hashes field if not present in resource
@@ -220,34 +293,45 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
} }
# Try to get info from Civitai if hash is available # Try to get info from Civitai if hash is available
if lora_entry["hash"] and metadata_provider: if lora_hash and metadata_provider:
try: local_cached = local_cache.get(lora_hash) if local_cache else None
civitai_info = ( if local_cached:
await metadata_provider.get_model_by_hash(lora_hash) self._populate_entry_from_cache(
lora_entry, local_cached
) )
# Track by version ID for deduplication
populated_entry = await self.populate_lora_from_civitai( if lora_entry.get("id"):
lora_entry,
civitai_info,
recipe_scanner,
base_model_counts,
lora_hash,
)
if populated_entry is None:
continue # Skip invalid LoRA types
lora_entry = populated_entry
# If we have a version ID from Civitai, track it for deduplication
if "id" in lora_entry and lora_entry["id"]:
added_loras[str(lora_entry["id"])] = len( added_loras[str(lora_entry["id"])] = len(
result["loras"] result["loras"]
) )
except Exception as e: else:
logger.error( try:
f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}" civitai_info = (
) await metadata_provider.get_model_by_hash(lora_hash)
)
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info,
recipe_scanner,
base_model_counts,
lora_hash,
)
if populated_entry is None:
continue # Skip invalid LoRA types
lora_entry = populated_entry
# If we have a version ID from Civitai, track it for deduplication
if "id" in lora_entry and lora_entry["id"]:
added_loras[str(lora_entry["id"])] = len(
result["loras"]
)
except Exception as e:
logger.error(
f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}"
)
# Track by hash if we have it # Track by hash if we have it
if lora_hash: if lora_hash:
@@ -625,3 +709,41 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
except Exception as e: except Exception as e:
logger.error(f"Error parsing Civitai image metadata: {e}", exc_info=True) logger.error(f"Error parsing Civitai image metadata: {e}", exc_info=True)
return {"error": str(e), "loras": []} return {"error": str(e), "loras": []}
@staticmethod
def _populate_entry_from_cache(
entry: dict[str, Any],
cache_item: dict[str, Any],
) -> None:
"""Fill a lora/checkpoint entry from a scanner cache item.
Avoids CivitAI API calls for models that exist locally.
Mirrors the population logic in
``RecipeMetadataParser.populate_lora_from_civitai()`` but operates
entirely on cached data.
"""
civ = cache_item.get("civitai") or {}
if isinstance(civ, dict):
if civ.get("id") is not None:
entry["id"] = civ["id"]
if civ.get("modelId") is not None:
entry["modelId"] = civ["modelId"]
if civ.get("name"):
entry["version"] = civ["name"]
cached_name = cache_item.get("model_name")
if cached_name:
entry["name"] = cached_name
entry["existsLocally"] = True
local_path = cache_item.get("file_path")
if local_path:
entry["localPath"] = local_path
sha256 = cache_item.get("sha256")
if sha256:
entry["hash"] = sha256
if "preview_url" in cache_item:
entry["thumbnailUrl"] = config.get_preview_static_url(
cache_item["preview_url"]
)
base_model = cache_item.get("base_model", "")
if base_model:
entry["baseModel"] = base_model

View File

@@ -686,6 +686,9 @@ class DoctorHandler:
) )
async def resolve_filename_conflicts(self, request: web.Request) -> web.Response: 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]] = [] renamed: list[dict[str, Any]] = []
try: try:
@@ -990,11 +993,29 @@ class DoctorHandler:
} }
async def _check_filename_conflicts(self) -> dict[str, Any]: 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]] = [] all_conflicts: list[dict[str, Any]] = []
total_conflict_groups = 0 total_conflict_groups = 0
total_conflict_files = 0 total_conflict_files = 0
for model_type, label, factory in self._scanner_factories: 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: try:
scanner = await factory() scanner = await factory()
hash_index = getattr(scanner, "_hash_index", None) hash_index = getattr(scanner, "_hash_index", None)
@@ -1042,12 +1063,22 @@ class DoctorHandler:
"total_conflict_files": total_conflict_files, "total_conflict_files": total_conflict_files,
} }
] ]
for conflict in all_conflicts:
# 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( details.append(
f"[{conflict['label']}] '{conflict['filename']}' " f"'{conflict['filename']}' "
f"found in {len(conflict['paths'])} locations" 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 { return {
"id": "filename_conflicts", "id": "filename_conflicts",
"title": "Duplicate Filename Conflicts", "title": "Duplicate Filename Conflicts",
@@ -1058,7 +1089,11 @@ class DoctorHandler:
{ {
"id": "resolve-filename-conflicts", "id": "resolve-filename-conflicts",
"label": "Resolve Conflicts", "label": "Resolve Conflicts",
} },
{
"id": "open-settings-syntax-format",
"label": "Switch to Full Path Syntax",
},
], ],
} }
@@ -2065,7 +2100,7 @@ class ModelLibraryHandler:
file_path=file_path if isinstance(file_path, str) else None, file_path=file_path if isinstance(file_path, str) else None,
) )
else: 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( return web.json_response(
{ {
@@ -2139,8 +2174,19 @@ class ModelLibraryHandler:
] ]
await found_cache.resort() 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() history_service = await self._get_download_history_service()
await history_service.mark_not_downloaded(found_type, model_version_id) await history_service.mark_as_deleted(found_type, model_version_id)
return web.json_response( return web.json_response(
{ {
@@ -3040,6 +3086,7 @@ class NodeRegistryHandler:
data = await request.json() data = await request.json()
widget_name = data.get("widget_name") widget_name = data.get("widget_name")
value = data.get("value") value = data.get("value")
mode = data.get("mode", "replace")
node_ids = data.get("node_ids") node_ids = data.get("node_ids")
if not isinstance(widget_name, str) or not widget_name: if not isinstance(widget_name, str) or not widget_name:
@@ -3087,6 +3134,7 @@ class NodeRegistryHandler:
"id": parsed_node_id, "id": parsed_node_id,
"widget_name": widget_name, "widget_name": widget_name,
"value": value, "value": value,
"mode": mode,
} }
if graph_identifier is not None: if graph_identifier is not None:

View File

@@ -37,6 +37,7 @@ from ...services.use_cases import (
) )
from ...services.websocket_manager import WebSocketManager from ...services.websocket_manager import WebSocketManager
from ...services.websocket_progress_callback import WebSocketProgressCallback from ...services.websocket_progress_callback import WebSocketProgressCallback
from ...services.download_queue_service import DownloadQueueService
from ...services.errors import RateLimitError, ResourceNotFoundError from ...services.errors import RateLimitError, ResourceNotFoundError
from ...utils.civitai_utils import resolve_license_payload from ...utils.civitai_utils import resolve_license_payload
from ...utils.file_utils import calculate_sha256 from ...utils.file_utils import calculate_sha256
@@ -301,6 +302,15 @@ class ModelListingHandler:
for tag in exclude_tags: for tag in exclude_tags:
if tag: if tag:
tag_filters[tag] = "exclude" 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" favorites_only = request.query.get("favorites_only", "false").lower() == "true"
search_options = { search_options = {
@@ -367,6 +377,7 @@ class ModelListingHandler:
"fuzzy_search": fuzzy_search, "fuzzy_search": fuzzy_search,
"base_models": base_models, "base_models": base_models,
"tags": tag_filters, "tags": tag_filters,
"auto_tags": auto_tag_filters,
"tag_logic": tag_logic, "tag_logic": tag_logic,
"search_options": search_options, "search_options": search_options,
"hash_filters": hash_filters, "hash_filters": hash_filters,
@@ -778,7 +789,7 @@ class ModelManagementHandler:
metadata_updates = {k: v for k, v in data.items() if k != "file_path"} 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, file_path=file_path,
updates=metadata_updates, updates=metadata_updates,
metadata_loader=self._metadata_sync.load_local_metadata, metadata_loader=self._metadata_sync.load_local_metadata,
@@ -789,7 +800,12 @@ class ModelManagementHandler:
cache = await self._service.scanner.get_cached_data() cache = await self._service.scanner.get_cached_data()
await cache.resort() 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: except Exception as exc:
self._logger.error("Error saving metadata: %s", exc, exc_info=True) self._logger.error("Error saving metadata: %s", exc, exc_info=True)
return web.Response(text=str(exc), status=500) return web.Response(text=str(exc), status=500)
@@ -806,14 +822,16 @@ class ModelManagementHandler:
if not isinstance(new_tags, list): if not isinstance(new_tags, list):
return web.Response(text="Tags must be a list", status=400) 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, file_path=file_path,
new_tags=new_tags, new_tags=new_tags,
metadata_loader=self._metadata_sync.load_local_metadata, metadata_loader=self._metadata_sync.load_local_metadata,
update_cache=self._service.scanner.update_single_model_cache, 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: except Exception as exc:
self._logger.error("Error adding tags: %s", exc, exc_info=True) self._logger.error("Error adding tags: %s", exc, exc_info=True)
return web.Response(text=str(exc), status=500) return web.Response(text=str(exc), status=500)
@@ -1160,6 +1178,12 @@ class ModelQueryHandler:
async def find_filename_conflicts(self, request: web.Request) -> web.Response: async def find_filename_conflicts(self, request: web.Request) -> web.Response:
try: 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() duplicates = self._service.find_duplicate_filenames()
result = [] result = []
cache = await self._service.scanner.get_cached_data() cache = await self._service.scanner.get_cached_data()
@@ -1449,6 +1473,21 @@ class ModelDownloadHandler:
) )
return web.Response(status=500, text=str(exc)) return web.Response(status=500, text=str(exc))
async def skip_download_get(self, request: web.Request) -> web.Response:
try:
download_id = request.query.get("download_id")
if not download_id:
return web.json_response(
{"success": False, "error": "Download ID is required"}, status=400
)
result = await self._download_coordinator.skip_download(download_id)
return web.json_response(result)
except Exception as exc:
self._logger.error(
"Error skipping download via GET: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def cancel_download_get(self, request: web.Request) -> web.Response: async def cancel_download_get(self, request: web.Request) -> web.Response:
try: try:
download_id = request.query.get("download_id") download_id = request.query.get("download_id")
@@ -1529,6 +1568,255 @@ class ModelDownloadHandler:
) )
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
# ------------------------------------------------------------------
# Download queue / history handlers
# ------------------------------------------------------------------
async def get_download_queue(self, request: web.Request) -> web.Response:
try:
service = await DownloadQueueService.get_instance()
queue = await service.get_queue()
stats = await service.get_stats()
return web.json_response({"success": True, "queue": queue, "stats": stats})
except Exception as exc:
self._logger.error(
"Error getting download queue: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def add_to_download_queue(self, request: web.Request) -> web.Response:
try:
import uuid
download_id = request.query.get("download_id") or str(uuid.uuid4())
model_id_str = request.query.get("model_id")
model_version_id_str = request.query.get("model_version_id")
model_name = request.query.get("model_name", "")
version_name = request.query.get("version_name", "")
thumbnail_url = request.query.get("thumbnail_url", "")
source = request.query.get("source")
file_params_json = request.query.get("file_params")
model_id = int(model_id_str) if model_id_str else None
model_version_id = int(model_version_id_str) if model_version_id_str else None
file_params = json.loads(file_params_json) if file_params_json else None
service = await DownloadQueueService.get_instance()
item = await service.add_to_queue(
download_id=download_id,
model_id=model_id,
model_version_id=model_version_id,
model_name=model_name,
version_name=version_name,
thumbnail_url=thumbnail_url,
source=source,
file_params=file_params,
)
return web.json_response({"success": True, "item": item})
except Exception as exc:
self._logger.error(
"Error adding to download queue: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def remove_from_download_queue(self, request: web.Request) -> web.Response:
try:
download_id = request.query.get("download_id")
if not download_id:
return web.json_response(
{"success": False, "error": "download_id is required"}, status=400
)
service = await DownloadQueueService.get_instance()
removed = await service.remove_from_queue(download_id)
return web.json_response({"success": removed})
except Exception as exc:
self._logger.error(
"Error removing from download queue: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_queue_item_to_top(self, request: web.Request) -> web.Response:
try:
download_id = request.query.get("download_id")
if not download_id:
return web.json_response(
{"success": False, "error": "download_id is required"}, status=400
)
service = await DownloadQueueService.get_instance()
moved = await service.move_to_top(download_id)
return web.json_response({"success": moved})
except Exception as exc:
self._logger.error(
"Error moving queue item to top: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_queue_item_to_end(self, request: web.Request) -> web.Response:
try:
download_id = request.query.get("download_id")
if not download_id:
return web.json_response(
{"success": False, "error": "download_id is required"}, status=400
)
service = await DownloadQueueService.get_instance()
moved = await service.move_to_end(download_id)
return web.json_response({"success": moved})
except Exception as exc:
self._logger.error(
"Error moving queue item to end: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def clear_download_queue(self, request: web.Request) -> web.Response:
try:
status_filter = request.query.get("status") or None
service = await DownloadQueueService.get_instance()
cleared = await service.clear_queue(status_filter=status_filter)
return web.json_response({"success": True, "cleared": cleared})
except Exception as exc:
self._logger.error(
"Error clearing download queue: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_download_history(self, request: web.Request) -> web.Response:
try:
limit = min(int(request.query.get("limit", "50")), 500)
offset = int(request.query.get("offset", "0"))
status_filter = request.query.get("status") or None
service = await DownloadQueueService.get_instance()
result = await service.get_history(
limit=limit, offset=offset, status_filter=status_filter
)
return web.json_response(
{
"success": True,
"items": result["items"],
"total": result["total"],
"limit": result["limit"],
"offset": result["offset"],
}
)
except Exception as exc:
self._logger.error(
"Error getting download history: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def clear_download_history(self, request: web.Request) -> web.Response:
try:
status_filter = request.query.get("status") or None
service = await DownloadQueueService.get_instance()
cleared = await service.clear_history(status_filter=status_filter)
return web.json_response({"success": True, "cleared": cleared})
except Exception as exc:
self._logger.error(
"Error clearing download history: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def delete_download_history_item(self, request: web.Request) -> web.Response:
try:
item_id = int(request.query.get("id", "0"))
if not item_id:
return web.json_response(
{"success": False, "error": "id is required"}, status=400
)
service = await DownloadQueueService.get_instance()
deleted = await service.delete_history_item(item_id)
return web.json_response({"success": deleted})
except Exception as exc:
self._logger.error(
"Error deleting download history item: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def retry_download_from_history(self, request: web.Request) -> web.Response:
try:
item_id = int(request.query.get("id", "0"))
if not item_id:
return web.json_response(
{"success": False, "error": "id is required"}, status=400
)
service = await DownloadQueueService.get_instance()
item = await service.retry_from_history(item_id)
if item is None:
return web.json_response(
{"success": False, "error": "History item not found or not retryable"},
status=404,
)
return web.json_response({"success": True, "item": item})
except Exception as exc:
self._logger.error(
"Error retrying download from history: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def retry_all_failed_downloads(self, request: web.Request) -> web.Response:
try:
service = await DownloadQueueService.get_instance()
retry_count = await service.retry_all_failed()
return web.json_response({"success": True, "retry_count": retry_count})
except Exception as exc:
self._logger.error(
"Error retrying all failed downloads: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def complete_download_in_queue(self, request: web.Request) -> web.Response:
"""Atomically move a download from queue to history with terminal status."""
try:
download_id = request.query.get("download_id")
if not download_id:
return web.json_response(
{"success": False, "error": "download_id is required"}, status=400
)
status = request.query.get("status", "completed")
error = request.query.get("error")
file_path = request.query.get("file_path")
try:
bytes_downloaded = int(request.query.get("bytes_downloaded", "0"))
except (TypeError, ValueError):
bytes_downloaded = 0
total_bytes_raw = request.query.get("total_bytes")
total_bytes = int(total_bytes_raw) if total_bytes_raw else None
service = await DownloadQueueService.get_instance()
item = await service.complete_download(
download_id=download_id,
status=status,
error=error,
file_path=file_path,
bytes_downloaded=bytes_downloaded,
total_bytes=total_bytes,
)
if item is None:
return web.json_response(
{"success": False, "error": "Download not found in queue"}, status=404
)
return web.json_response({"success": True, "item": item})
except Exception as exc:
self._logger.error(
"Error completing download: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_download_stats(self, request: web.Request) -> web.Response:
try:
service = await DownloadQueueService.get_instance()
stats = await service.get_stats()
return web.json_response({"success": True, "stats": stats})
except Exception as exc:
self._logger.error(
"Error getting download stats: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelCivitaiHandler: class ModelCivitaiHandler:
"""CivitAI integration endpoints.""" """CivitAI integration endpoints."""
@@ -1937,6 +2225,10 @@ class ModelUpdateHandler:
if target_model_ids: if target_model_ids:
target_model_ids = sorted(set(target_model_ids)) target_model_ids = sorted(set(target_model_ids))
folder_path: Optional[str] = payload.get("folder_path")
if folder_path is not None and not isinstance(folder_path, str):
folder_path = None
provider = await self._get_civitai_provider() provider = await self._get_civitai_provider()
if provider is None: if provider is None:
return web.json_response( return web.json_response(
@@ -1951,6 +2243,7 @@ class ModelUpdateHandler:
provider, provider,
force_refresh=force_refresh, force_refresh=force_refresh,
target_model_ids=target_model_ids or None, target_model_ids=target_model_ids or None,
folder_path=folder_path,
) )
if self._service.scanner.is_cancelled(): if self._service.scanner.is_cancelled():
return web.json_response( return web.json_response(
@@ -1973,10 +2266,21 @@ class ModelUpdateHandler:
self._logger.error("Failed to refresh model updates: %s", exc, exc_info=True) self._logger.error("Failed to refresh model updates: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
hide_early_access = False
if self._settings is not None:
try:
hide_early_access = bool(
self._settings.get("hide_early_access_updates", False)
)
except Exception:
pass
serialized_records = [] serialized_records = []
for record in records.values(): for record in records.values():
has_update_fn = getattr(record, "has_update", None) has_update_fn = getattr(record, "has_update", None)
if callable(has_update_fn) and has_update_fn(): if callable(has_update_fn) and has_update_fn(
hide_early_access=hide_early_access
):
serialized_records.append(self._serialize_record(record)) serialized_records.append(self._serialize_record(record))
return web.json_response( return web.json_response(
@@ -2538,9 +2842,23 @@ class ModelHandlerSet:
"download_model": self.download.download_model, "download_model": self.download.download_model,
"download_model_get": self.download.download_model_get, "download_model_get": self.download.download_model_get,
"cancel_download_get": self.download.cancel_download_get, "cancel_download_get": self.download.cancel_download_get,
"skip_download_get": self.download.skip_download_get,
"pause_download_get": self.download.pause_download_get, "pause_download_get": self.download.pause_download_get,
"resume_download_get": self.download.resume_download_get, "resume_download_get": self.download.resume_download_get,
"get_download_progress": self.download.get_download_progress, "get_download_progress": self.download.get_download_progress,
"get_download_queue": self.download.get_download_queue,
"add_to_download_queue": self.download.add_to_download_queue,
"remove_from_download_queue": self.download.remove_from_download_queue,
"move_queue_item_to_top": self.download.move_queue_item_to_top,
"move_queue_item_to_end": self.download.move_queue_item_to_end,
"clear_download_queue": self.download.clear_download_queue,
"get_download_history": self.download.get_download_history,
"clear_download_history": self.download.clear_download_history,
"delete_download_history_item": self.download.delete_download_history_item,
"retry_download_from_history": self.download.retry_download_from_history,
"retry_all_failed_downloads": self.download.retry_all_failed_downloads,
"complete_download_in_queue": self.download.complete_download_in_queue,
"get_download_stats": self.download.get_download_stats,
"get_civitai_versions": self.civitai.get_civitai_versions, "get_civitai_versions": self.civitai.get_civitai_versions,
"get_civitai_model_by_version": self.civitai.get_civitai_model_by_version, "get_civitai_model_by_version": self.civitai.get_civitai_model_by_version,
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash, "get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import mimetypes
import urllib.parse import urllib.parse
from pathlib import Path from pathlib import Path
@@ -12,6 +13,12 @@ from ...config import config as global_config
logger = logging.getLogger(__name__) 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: class PreviewHandler:
"""Serve preview assets for the active library at request time.""" """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)) logger.debug("Preview file not found at %s", str(resolved))
raise web.HTTPNotFound(text="Preview file not found") 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. # 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"] __all__ = ["PreviewHandler"]

File diff suppressed because it is too large Load Diff

View File

@@ -101,11 +101,43 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/download-model", "download_model"), RouteDefinition("POST", "/api/lm/download-model", "download_model"),
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"), RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"), RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
RouteDefinition("GET", "/api/lm/skip-download", "skip_download_get"),
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"), RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"), RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
RouteDefinition( RouteDefinition(
"GET", "/api/lm/download-progress/{download_id}", "get_download_progress" "GET", "/api/lm/download-progress/{download_id}", "get_download_progress"
), ),
RouteDefinition("GET", "/api/lm/downloads/queue", "get_download_queue"),
RouteDefinition("GET", "/api/lm/downloads/queue/add", "add_to_download_queue"),
RouteDefinition(
"GET", "/api/lm/downloads/queue/remove", "remove_from_download_queue"
),
RouteDefinition(
"GET", "/api/lm/downloads/queue/move-to-top", "move_queue_item_to_top"
),
RouteDefinition(
"GET", "/api/lm/downloads/queue/move-to-end", "move_queue_item_to_end"
),
RouteDefinition(
"GET", "/api/lm/downloads/queue/clear", "clear_download_queue"
),
RouteDefinition("GET", "/api/lm/downloads/history", "get_download_history"),
RouteDefinition(
"GET", "/api/lm/downloads/history/clear", "clear_download_history"
),
RouteDefinition(
"GET", "/api/lm/downloads/history/delete", "delete_download_history_item"
),
RouteDefinition(
"GET", "/api/lm/downloads/history/retry", "retry_download_from_history"
),
RouteDefinition(
"GET", "/api/lm/downloads/history/retry-all", "retry_all_failed_downloads"
),
RouteDefinition("GET", "/api/lm/downloads/stats", "get_download_stats"),
RouteDefinition(
"GET", "/api/lm/downloads/queue/complete", "complete_download_in_queue"
),
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"), RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
RouteDefinition("GET", "/{prefix}", "handle_models_page"), RouteDefinition("GET", "/{prefix}", "handle_models_page"),
) )

View File

@@ -58,6 +58,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"), RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"), RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"), 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("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"), RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
RouteDefinition( RouteDefinition(
@@ -70,6 +71,16 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import" "POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
), ),
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"), 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"),
RouteDefinition(
"POST", "/api/lm/recipes/create-from-example", "create_from_example"
),
RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
),
) )

View File

@@ -1,7 +1,6 @@
import os import os
import logging import logging
import toml import toml
import git
import zipfile import zipfile
import shutil import shutil
import tempfile import tempfile
@@ -11,6 +10,7 @@ from typing import Dict, List
from ..utils.settings_paths import ensure_settings_file from ..utils.settings_paths import ensure_settings_file
from ..services.downloader import get_downloader from ..services.downloader import get_downloader
from ..services.service_registry import ServiceRegistry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -212,8 +212,19 @@ class UpdateRoutes:
zip_path = tmp_zip_path zip_path = tmp_zip_path
# Skip both settings.json, civitai and model cache folder # Close the downloaded-versions SQLite connection before cleaning,
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache']) # so that shutil.rmtree() does not fail on Windows (the process
# cannot delete a file with an outstanding open handle).
try:
history_svc = ServiceRegistry._services.get("downloaded_version_history_service")
if history_svc is not None:
history_svc.close()
logger.info("Closed downloaded-version history database connection")
except Exception:
logger.debug("Could not close downloaded-version history database", exc_info=True)
# Skip settings.json, civitai, model cache and runtime cache folders
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups', 'stats'])
# Extract ZIP to temp dir # Extract ZIP to temp dir
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
@@ -222,16 +233,17 @@ class UpdateRoutes:
# Find extracted folder (GitHub ZIP contains a root folder) # Find extracted folder (GitHub ZIP contains a root folder)
extracted_root = next(os.scandir(tmp_dir)).path extracted_root = next(os.scandir(tmp_dir)).path
# Copy files, skipping settings.json and civitai folder # Copy files, skipping user data that should be preserved
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups', 'stats'}
for item in os.listdir(extracted_root): for item in os.listdir(extracted_root):
if item == 'settings.json' or item == 'civitai': if item in skip_items:
continue continue
src = os.path.join(extracted_root, item) src = os.path.join(extracted_root, item)
dst = os.path.join(plugin_root, item) dst = os.path.join(plugin_root, item)
if os.path.isdir(src): if os.path.isdir(src):
if os.path.exists(dst): if os.path.exists(dst):
shutil.rmtree(dst) shutil.rmtree(dst)
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json', 'civitai')) shutil.copytree(src, dst, ignore=shutil.ignore_patterns(*skip_items))
else: else:
shutil.copy2(src, dst) shutil.copy2(src, dst)
@@ -239,15 +251,17 @@ class UpdateRoutes:
# for ComfyUI Manager to work properly # for ComfyUI Manager to work properly
tracking_info_file = os.path.join(plugin_root, '.tracking') tracking_info_file = os.path.join(plugin_root, '.tracking')
tracking_files = [] tracking_files = []
skip_tracked = {'civitai', 'wildcards', 'backups', 'stats'}
for root, dirs, files in os.walk(extracted_root): for root, dirs, files in os.walk(extracted_root):
# Skip civitai folder and its contents # Skip user data directories and their contents
rel_root = os.path.relpath(root, extracted_root) rel_root = os.path.relpath(root, extracted_root)
if rel_root == 'civitai' or rel_root.startswith('civitai' + os.sep): top_dir = rel_root.split(os.sep)[0] if rel_root != '.' else ''
if top_dir in skip_tracked:
continue continue
for file in files: for file in files:
rel_path = os.path.relpath(os.path.join(root, file), extracted_root) rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
# Skip settings.json and any file under civitai # Skip settings.json and any file under user data dirs
if rel_path == 'settings.json' or rel_path.startswith('civitai' + os.sep): if rel_path == 'settings.json' or rel_path.split(os.sep)[0] in skip_tracked:
continue continue
tracking_files.append(rel_path.replace("\\", "/")) tracking_files.append(rel_path.replace("\\", "/"))
with open(tracking_info_file, "w", encoding='utf-8') as file: with open(tracking_info_file, "w", encoding='utf-8') as file:
@@ -342,6 +356,15 @@ class UpdateRoutes:
Returns: Returns:
tuple: (success, new_version) tuple: (success, new_version)
""" """
try:
import git
except ImportError:
logger.error(
"GitPython is not available: the git executable was not found in PATH. "
"Install git or set $GIT_PYTHON_GIT_EXECUTABLE to the git binary path."
)
return False, ""
try: try:
# Open the Git repository # Open the Git repository
repo = git.Repo(plugin_root) repo = git.Repo(plugin_root)
@@ -438,6 +461,7 @@ class UpdateRoutes:
if not os.path.exists(os.path.join(plugin_root, '.git')): if not os.path.exists(os.path.join(plugin_root, '.git')):
return git_info return git_info
import git
repo = git.Repo(plugin_root) repo = git.Repo(plugin_root)
commit = repo.head.commit commit = repo.head.commit
git_info['commit_hash'] = commit.hexsha git_info['commit_hash'] = commit.hexsha

View File

@@ -14,12 +14,30 @@ from typing import Any, Dict, Optional, Tuple
import aiohttp import aiohttp
from .downloader import DownloadProgress, get_downloader from .downloader import DownloadProgress, get_downloader, is_ssl_cert_verify_error
from .aria2_transfer_state import Aria2TransferStateStore from .aria2_transfer_state import Aria2TransferStateStore
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _try_certifi_ca_path() -> str | None:
"""Return the certifi CA bundle path if available, else None."""
try:
import certifi # type: ignore[import-untyped]
path = certifi.where()
if os.path.isfile(path):
logger.debug(
"aria2 --ca-certificate: using certifi CA bundle at %s", path
)
return path
except ImportError:
pass
logger.debug("aria2 --ca-certificate: certifi not available")
return None
CIVITAI_DOWNLOAD_URL_PREFIXES = ( CIVITAI_DOWNLOAD_URL_PREFIXES = (
"https://civitai.com/api/download/", "https://civitai.com/api/download/",
"https://civitai.red/api/download/", "https://civitai.red/api/download/",
@@ -39,7 +57,7 @@ class Aria2Transfer:
class Aria2Downloader: class Aria2Downloader:
"""Manage an aria2 RPC daemon for experimental model downloads.""" """Manage an aria2 RPC daemon for recommended model downloads."""
_instance = None _instance = None
_lock = asyncio.Lock() _lock = asyncio.Lock()
@@ -391,6 +409,15 @@ class Aria2Downloader:
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}" f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
) )
except aiohttp.ClientError as exc: except aiohttp.ClientError as exc:
if is_ssl_cert_verify_error(exc):
logger.error(
"SSL certificate verification failed during Civitai redirect "
"resolution for %s. This is usually caused by an outdated CA "
"certificate bundle. Recommended fixes:\n"
" 1. pip install --upgrade certifi\n"
" 2. pip install pip-system-certs",
url,
)
raise Aria2Error( raise Aria2Error(
f"Failed to resolve authenticated Civitai redirect: {exc}" f"Failed to resolve authenticated Civitai redirect: {exc}"
) from exc ) from exc
@@ -414,6 +441,11 @@ class Aria2Downloader:
f"--rpc-listen-port={self._rpc_port}", f"--rpc-listen-port={self._rpc_port}",
f"--rpc-secret={self._rpc_secret}", f"--rpc-secret={self._rpc_secret}",
"--check-certificate=true", "--check-certificate=true",
# Point aria2 at certifi's CA bundle when available so it uses
# the same certificate store as Python downloads.
*((
f"--ca-certificate={ca_cert}",
) if (ca_cert := _try_certifi_ca_path()) else ()),
"--allow-overwrite=true", "--allow-overwrite=true",
"--auto-file-renaming=false", "--auto-file-renaming=false",
"--file-allocation=none", "--file-allocation=none",

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

@@ -141,6 +141,16 @@ class BackupService:
) )
) )
stats_path = os.path.join(get_settings_dir(create=True), "stats", "lora_manager_stats.json")
if os.path.exists(stats_path):
targets.append(
(
"usage_stats",
"stats/lora_manager_stats.json",
stats_path,
)
)
return targets return targets
@staticmethod @staticmethod
@@ -348,6 +358,8 @@ class BackupService:
if kind == "model_update": if kind == "model_update":
filename = os.path.basename(archive_member) filename = os.path.basename(archive_member)
return str(Path(get_cache_file_path(CacheType.MODEL_UPDATE, create_dir=True)).parent / filename) return str(Path(get_cache_file_path(CacheType.MODEL_UPDATE, create_dir=True)).parent / filename)
if kind == "usage_stats":
return os.path.join(get_settings_dir(create=True), "stats", "lora_manager_stats.json")
return None return None
async def create_auto_snapshot_if_due(self) -> Optional[dict[str, Any]]: async def create_auto_snapshot_if_due(self) -> Optional[dict[str, Any]]:

View File

@@ -77,6 +77,7 @@ class BaseModelService(ABC):
base_models: list = None, base_models: list = None,
model_types: list = None, model_types: list = None,
tags: Optional[Dict[str, str]] = None, tags: Optional[Dict[str, str]] = None,
auto_tags: Optional[Dict[str, str]] = None,
search_options: dict = None, search_options: dict = None,
hash_filters: dict = None, hash_filters: dict = None,
favorites_only: bool = False, favorites_only: bool = False,
@@ -95,6 +96,11 @@ class BaseModelService(ABC):
sorted_data = await self._fetch_with_usage_sort(sort_params) sorted_data = await self._fetch_with_usage_sort(sort_params)
else: else:
sorted_data = await self.cache_repository.fetch_sorted(sort_params) 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 fetch_duration = time.perf_counter() - t0
initial_count = len(sorted_data) initial_count = len(sorted_data)
@@ -110,6 +116,7 @@ class BaseModelService(ABC):
base_models=base_models, base_models=base_models,
model_types=model_types, model_types=model_types,
tags=tags, tags=tags,
auto_tags=auto_tags,
favorites_only=favorites_only, favorites_only=favorites_only,
search_options=search_options, search_options=search_options,
tag_logic=tag_logic, tag_logic=tag_logic,
@@ -354,6 +361,7 @@ class BaseModelService(ABC):
base_models: list = None, base_models: list = None,
model_types: list = None, model_types: list = None,
tags: Optional[Dict[str, str]] = None, tags: Optional[Dict[str, str]] = None,
auto_tags: Optional[Dict[str, str]] = None,
favorites_only: bool = False, favorites_only: bool = False,
search_options: dict = None, search_options: dict = None,
tag_logic: str = "any", tag_logic: str = "any",
@@ -367,6 +375,7 @@ class BaseModelService(ABC):
base_models=base_models, base_models=base_models,
model_types=model_types, model_types=model_types,
tags=tags, tags=tags,
auto_tags=auto_tags,
favorites_only=favorites_only, favorites_only=favorites_only,
search_options=normalized_options, search_options=normalized_options,
tag_logic=tag_logic, tag_logic=tag_logic,
@@ -861,22 +870,75 @@ class BaseModelService(ABC):
"""Get the static preview URL for a model file""" """Get the static preview URL for a model file"""
cache = await self.scanner.get_cached_data() 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: 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") preview_url = model.get("preview_url")
if preview_url: if preview_url:
from ..config import config from ..config import config
return config.get_preview_static_url(preview_url) 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" return "/loras_static/images/no-preview.png"
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]: async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
"""Get the Civitai URL for a model file""" """Get the Civitai URL for a model file"""
cache = await self.scanner.get_cached_data() 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: 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", {}) civitai_data = model.get("civitai", {})
model_id = civitai_data.get("modelId") model_id = civitai_data.get("modelId")
version_id = civitai_data.get("id") version_id = civitai_data.get("id")
@@ -895,6 +957,27 @@ class BaseModelService(ABC):
"version_id": str(version_id) if version_id else None, "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} return {"civitai_url": None, "model_id": None, "version_id": None}
async def get_model_metadata(self, file_path: str) -> Optional[Dict]: 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: if should_skip or metadata is None:
return 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", {})) return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
async def get_model_description(self, file_path: str) -> Optional[str]: async def get_model_description(self, file_path: str) -> Optional[str]:

View File

@@ -224,7 +224,7 @@ class BatchImportService:
return False return False
for recipe in getattr(cache, "raw_data", []): 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: if source_path and source_path == source:
return True return True
return False return False

View File

@@ -3,6 +3,7 @@ import logging
from typing import Dict from typing import Dict
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags
from ..utils.models import CheckpointMetadata from ..utils.models import CheckpointMetadata
from ..config import config from ..config import config
@@ -45,7 +46,8 @@ class CheckpointService(BaseModelService):
"exclude": bool(checkpoint_data.get("exclude", False)), "exclude": bool(checkpoint_data.get("exclude", False)),
"update_available": bool(checkpoint_data.get("update_available", False)), "update_available": bool(checkpoint_data.get("update_available", False)),
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", 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: def find_duplicate_hashes(self) -> Dict:

View File

@@ -186,6 +186,22 @@ class CivArchiveClient:
if "metadata" in file_data: if "metadata" in file_data:
transformed["metadata"] = file_data["metadata"] transformed["metadata"] = file_data["metadata"]
# Infer metadata.format from filename extension
name = transformed.get("name")
if name and isinstance(name, str):
lower_name = name.lower()
if lower_name.endswith(".safetensors"):
inferred_format = "SafeTensor"
elif lower_name.endswith(".ckpt"):
inferred_format = "PickleTensor"
else:
inferred_format = None
if inferred_format:
if "metadata" not in transformed:
transformed["metadata"] = {}
if isinstance(transformed["metadata"], dict):
transformed["metadata"].setdefault("format", inferred_format)
if file_data.get("modelVersionId") is not None: if file_data.get("modelVersionId") is not None:
transformed["modelVersionId"] = file_data.get("modelVersionId") transformed["modelVersionId"] = file_data.get("modelVersionId")
elif file_data.get("model_version_id") is not None: elif file_data.get("model_version_id") is not None:
@@ -213,6 +229,20 @@ class CivArchiveClient:
for file_data in candidates: for file_data in candidates:
if isinstance(file_data, dict): if isinstance(file_data, dict):
transformed_files.append(self._transform_file_entry(file_data)) transformed_files.append(self._transform_file_entry(file_data))
# Sort: .safetensors first, .ckpt second, others last
# so the backend fallback (no file_params) prefers safetensors
def _sort_key(f: Dict) -> int:
fname = f.get("name") or ""
if isinstance(fname, str):
lower = fname.lower()
if lower.endswith(".safetensors"):
return 0
elif lower.endswith(".ckpt"):
return 1
return 2
transformed_files.sort(key=_sort_key)
return transformed_files return transformed_files
def _transform_version( def _transform_version(

View File

@@ -2,6 +2,7 @@ import asyncio
import copy import copy
import logging import logging
import os import os
from collections import OrderedDict
from typing import Any, Optional, Dict, Tuple, List, Sequence from typing import Any, Optional, Dict, Tuple, List, Sequence
from .connectivity_guard import ( from .connectivity_guard import (
OFFLINE_FRIENDLY_MESSAGE, OFFLINE_FRIENDLY_MESSAGE,
@@ -45,6 +46,14 @@ class CivitaiClient:
self._initialized = True self._initialized = True
self.base_url = "https://civitai.red/api/v1" 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: def _build_image_info_url(self, image_id: str) -> str:
return f"{self.base_url}/images?imageId={image_id}&nsfw=X" return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
@@ -57,22 +66,57 @@ class CivitaiClient:
use_auth: bool = False, use_auth: bool = False,
**kwargs, **kwargs,
) -> Tuple[bool, Dict | str]: ) -> 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() max_retries = 3
success, result = await downloader.make_request( for attempt in range(max_retries):
method, downloader = await get_downloader()
url, success, result = await downloader.make_request(
use_auth=use_auth, method,
**kwargs, url,
) use_auth=use_auth,
if not success and isinstance(result, RateLimitError): **kwargs,
if result.provider is None: )
result.provider = "civitai_api" if success:
raise result return True, result
if not success and is_offline_cooldown_error(result):
return False, OFFLINE_FRIENDLY_MESSAGE if isinstance(result, RateLimitError):
return success, result 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 @staticmethod
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None: def _remove_comfy_metadata(model_version: Optional[Dict]) -> None:
@@ -201,6 +245,29 @@ class CivitaiClient:
return _from_value(payload) 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]: async def get_model_versions(self, model_id: str) -> Optional[Dict]:
"""Get all versions of a model with local availability info""" """Get all versions of a model with local availability info"""
try: try:
@@ -223,6 +290,13 @@ class CivitaiClient:
logger.info("Civitai request skipped: %s", OFFLINE_FRIENDLY_MESSAGE) logger.info("Civitai request skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
return None return None
if message: 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) raise RuntimeError(message)
return None return None
except RateLimitError: except RateLimitError:
@@ -257,7 +331,7 @@ class CivitaiClient:
"GET", "GET",
f"{self.base_url}/models", f"{self.base_url}/models",
use_auth=True, use_auth=True,
params={"ids": query}, params={"ids": query, "nsfw": "true"},
) )
if not success: if not success:
return None return None
@@ -336,6 +410,25 @@ class CivitaiClient:
return None return None
target_version = self._select_target_version(model_data, model_id, version_id) 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: if target_version is None:
return None return None
@@ -482,6 +575,14 @@ class CivitaiClient:
- The model version data or None if not found - The model version data or None if not found
- An error message if there was an error, or None on success - 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: try:
url = f"{self.base_url}/model-versions/{version_id}" url = f"{self.base_url}/model-versions/{version_id}"
@@ -491,6 +592,11 @@ class CivitaiClient:
if success: if success:
logger.debug("Successfully fetched model version info for: %s", version_id) logger.debug("Successfully fetched model version info for: %s", version_id)
self._remove_comfy_metadata(result) 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 return result, None
# Handle specific error cases # Handle specific error cases
@@ -532,6 +638,13 @@ class CivitaiClient:
if not success: if not success:
if is_expected_offline_error(result): if is_expected_offline_error(result):
return None 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( logger.error(
"Failed to fetch image info for ID %s from civitai.red: %s", "Failed to fetch image info for ID %s from civitai.red: %s",
image_id, image_id,
@@ -640,7 +753,7 @@ class CivitaiClient:
"GET", "GET",
f"{self.base_url}/models", f"{self.base_url}/models",
use_auth=True, use_auth=True,
params={"username": username}, params={"username": username, "nsfw": "true"},
) )
if not success: if not success:

View File

@@ -110,6 +110,23 @@ class DownloadCoordinator:
return result return result
async def skip_download(self, download_id: str) -> Dict[str, Any]:
"""Skip a download while preserving all partial files on disk."""
download_manager = await self._download_manager_factory()
result = await download_manager.skip_download(download_id)
await self._ws_manager.broadcast_download_progress(
download_id,
{
"status": "skipped",
"progress": 0,
"download_id": download_id,
"message": "Download skipped by user (partial files preserved)",
},
)
return result
async def pause_download(self, download_id: str) -> Dict[str, Any]: async def pause_download(self, download_id: str) -> Dict[str, Any]:
"""Pause an active download and notify listeners.""" """Pause an active download and notify listeners."""

View File

@@ -18,6 +18,7 @@ from ..utils.constants import (
VALID_LORA_TYPES, VALID_LORA_TYPES,
) )
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url 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.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name from ..utils.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
@@ -2239,8 +2240,11 @@ class DownloadManager:
entry.file_name = os.path.splitext(os.path.basename(file_path))[0] entry.file_name = os.path.splitext(os.path.basename(file_path))[0]
# Update size to actual downloaded file size # Update size to actual downloaded file size
entry.size = os.path.getsize(file_path) entry.size = os.path.getsize(file_path)
# Use SHA256 from API metadata (already set in from_civitai_info) # Compute SHA256 locally when the API response didn't include it
# Do not recalculate to avoid blocking during ComfyUI execution if not entry.sha256:
sha256 = await calculate_sha256(file_path)
if sha256:
entry.sha256 = sha256.lower()
entries.append(entry) entries.append(entry)
return entries return entries
@@ -2400,6 +2404,89 @@ class DownloadManager:
self._download_tasks.pop(download_id, None) self._download_tasks.pop(download_id, None)
await self._aria2_state_store.remove(download_id) await self._aria2_state_store.remove(download_id)
async def skip_download(self, download_id: str) -> Dict:
"""Skip a download while preserving all partial files on disk.
Removes all in-memory tracking (asyncio task, semaphore, active/pause
state) but keeps partial files (.part / .aria2) on disk so that a
subsequent download-model-get request for the same save path can
auto-resume from the preserved partial download.
Args:
download_id: The unique identifier of the download task
Returns:
Dict: Status of the skip operation
"""
await self._restore_persisted_downloads()
if download_id not in self._download_tasks and download_id not in self._active_downloads:
return {"success": False, "error": "Download task not found"}
download_info = self._active_downloads.get(download_id)
task = self._download_tasks.get(download_id)
active_statuses = {"queued", "waiting", "downloading", "paused", "cancelling"}
if task is None and (
not isinstance(download_info, dict)
or download_info.get("status") not in active_statuses
):
return {"success": False, "error": "Download task not found"}
backend = (
self._active_downloads.get(download_id, {}).get("transfer_backend")
or "python"
)
try:
# For aria2: pause the transfer rather than force-removing it, so
# the .aria2 control file stays on disk for future resume
if backend == "aria2":
try:
aria2_downloader = await get_aria2_downloader()
pause_result = await aria2_downloader.pause_download(download_id)
if not pause_result.get("success"):
logger.warning(
"Failed to pause aria2 transfer for %s during skip: %s",
download_id,
pause_result.get("error"),
)
except Exception as exc:
logger.warning(
"Failed to pause aria2 transfer for %s during skip: %s",
download_id,
exc,
)
# Cancel the asyncio task so the semaphore slot is released
if task is not None:
task.cancel()
# Resume pause event so the task can exit cleanly
pause_control = self._pause_events.get(download_id)
if pause_control is not None:
pause_control.resume()
# Wait briefly for task to acknowledge cancellation
if task is not None:
try:
await asyncio.wait_for(asyncio.shield(task), timeout=2.0)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
logger.info(f"Download skipped for task {download_id} (partial files preserved)")
return {"success": True, "message": "Download skipped successfully"}
except Exception as e:
logger.error(f"Error skipping download: {e}", exc_info=True)
return {"success": False, "error": str(e)}
finally:
# Clean up local in-memory tracking only - NO file deletion
self._pause_events.pop(download_id, None)
self._download_tasks.pop(download_id, None)
if download_id in self._active_downloads:
del self._active_downloads[download_id]
# Preserve aria2 state store entry so the partial download
# info survives restarts and can be resumed later
async def pause_download(self, download_id: str) -> Dict: async def pause_download(self, download_id: str) -> Dict:
"""Pause an active download without losing progress.""" """Pause an active download without losing progress."""

View File

@@ -0,0 +1,730 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import sqlite3
import time
from typing import Any, Optional
from ..utils.cache_paths import get_cache_base_dir
logger = logging.getLogger(__name__)
def _resolve_database_path() -> str:
base_dir = get_cache_base_dir(create=True)
history_dir = os.path.join(base_dir, "download_history")
os.makedirs(history_dir, exist_ok=True)
return os.path.join(history_dir, "download_queue.sqlite")
class DownloadQueueService:
"""Persistent download queue and history manager backed by SQLite.
Provides a singleton interface for managing a download queue and
corresponding history table, both stored in a single SQLite database
under the cache directory.
"""
_instance: Optional[DownloadQueueService] = None
_class_lock: asyncio.Lock = asyncio.Lock()
_SCHEMA = """
CREATE TABLE IF NOT EXISTS download_queue (
download_id TEXT PRIMARY KEY,
model_id INTEGER,
model_version_id INTEGER,
model_name TEXT NOT NULL DEFAULT '',
version_name TEXT DEFAULT '',
thumbnail_url TEXT DEFAULT '',
source TEXT,
file_params TEXT,
status TEXT NOT NULL DEFAULT 'queued',
priority INTEGER DEFAULT 0,
progress INTEGER DEFAULT 0,
bytes_downloaded INTEGER DEFAULT 0,
total_bytes INTEGER,
bytes_per_second REAL DEFAULT 0.0,
error TEXT,
file_path TEXT,
added_at REAL NOT NULL,
started_at REAL,
completed_at REAL
);
CREATE INDEX IF NOT EXISTS idx_dq_status ON download_queue(status);
CREATE INDEX IF NOT EXISTS idx_dq_added ON download_queue(added_at);
CREATE TABLE IF NOT EXISTS download_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
download_id TEXT,
model_id INTEGER,
model_version_id INTEGER,
model_name TEXT NOT NULL DEFAULT '',
version_name TEXT DEFAULT '',
thumbnail_url TEXT DEFAULT '',
status TEXT NOT NULL,
error TEXT,
file_path TEXT,
bytes_downloaded INTEGER DEFAULT 0,
total_bytes INTEGER,
completed_at REAL NOT NULL,
is_already_exists INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
"""
@classmethod
async def get_instance(cls) -> DownloadQueueService:
"""Return the singleton instance, creating it if necessary."""
async with cls._class_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self, db_path: Optional[str] = None) -> None:
self._db_path = db_path or _resolve_database_path()
self._lock = asyncio.Lock()
self._conn: Optional[sqlite3.Connection] = None
self._schema_initialized = False
self._ensure_directory()
self._initialize_schema()
def _ensure_directory(self) -> None:
directory = os.path.dirname(self._db_path)
if directory:
os.makedirs(directory, exist_ok=True)
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path, check_same_thread=False)
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
with self._connect() as conn:
conn.executescript(self._SCHEMA)
conn.commit()
self._schema_initialized = True
def get_database_path(self) -> str:
"""Return the resolved database file path."""
return self._db_path
def close(self) -> None:
"""Close the persistent SQLite connection, if open.
This is called before plugin update operations to release the
database file lock on Windows, allowing ``shutil.rmtree()`` to
succeed when the cache resides inside the plugin directory.
"""
if self._conn is not None:
try:
self._conn.close()
except Exception:
pass
finally:
self._conn = None
# ------------------------------------------------------------------
# Queue methods
# ------------------------------------------------------------------
async def add_to_queue(
self,
download_id: str,
model_id: Optional[int] = None,
model_version_id: Optional[int] = None,
model_name: str = "",
version_name: str = "",
thumbnail_url: str = "",
source: Optional[str] = None,
file_params: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""Insert a new download into the queue.
Returns the inserted row as a dict (or an empty dict if the
download_id already exists).
"""
now = time.time()
file_params_json = json.dumps(file_params) if file_params is not None else None
async with self._lock:
conn = self._get_conn()
conn.execute(
"""
INSERT OR IGNORE INTO download_queue (
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, source, file_params,
status, priority, added_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
""",
(
download_id,
model_id,
model_version_id,
model_name,
version_name,
thumbnail_url,
source,
file_params_json,
now,
),
)
conn.commit()
row = conn.execute(
"SELECT * FROM download_queue WHERE download_id = ?",
(download_id,),
).fetchone()
return dict(row) if row else {}
async def get_queue(self) -> list[dict[str, Any]]:
"""Return all items in the queue ordered by priority then added time."""
async with self._lock:
conn = self._get_conn()
rows = conn.execute(
"SELECT * FROM download_queue ORDER BY priority DESC, added_at ASC"
).fetchall()
return [dict(row) for row in rows]
async def get_queued_count(self) -> int:
"""Return the number of items with status ``'queued'``."""
async with self._lock:
conn = self._get_conn()
row = conn.execute(
"SELECT COUNT(*) AS cnt FROM download_queue WHERE status = 'queued'"
).fetchone()
return row["cnt"] if row else 0
async def update_status(
self,
download_id: str,
status: str,
**extra: Any,
) -> bool:
"""Update the status and/or extra fields of a queue item.
Accepted extra keyword arguments:
``progress``, ``error``, ``file_path``, ``bytes_downloaded``,
``total_bytes``, ``bytes_per_second``.
Returns ``True`` if a row was updated.
"""
allowed_extra = {
"progress",
"error",
"file_path",
"bytes_downloaded",
"total_bytes",
"bytes_per_second",
}
set_clauses: list[str] = ["status = ?"]
params: list[Any] = [status]
now = time.time()
if status in ("downloading",):
set_clauses.append("started_at = COALESCE(started_at, ?)")
params.append(now)
if status in ("completed", "failed", "canceled"):
set_clauses.append("completed_at = ?")
params.append(now)
for key, value in extra.items():
if key in allowed_extra:
set_clauses.append(f"{key} = ?")
params.append(value)
params.append(download_id)
async with self._lock:
conn = self._get_conn()
cursor = conn.execute(
f"UPDATE download_queue SET {', '.join(set_clauses)} "
"WHERE download_id = ?",
params,
)
conn.commit()
return cursor.rowcount > 0
async def remove_from_queue(self, download_id: str) -> bool:
"""Remove a single item from the queue by download_id.
Returns ``True`` if a row was deleted.
"""
async with self._lock:
conn = self._get_conn()
cursor = conn.execute(
"DELETE FROM download_queue WHERE download_id = ?",
(download_id,),
)
conn.commit()
return cursor.rowcount > 0
async def move_to_top(self, download_id: str) -> bool:
"""Move an item to the front of the queue (highest priority).
Returns ``True`` if the item was found and updated.
"""
async with self._lock:
conn = self._get_conn()
row = conn.execute(
"SELECT priority FROM download_queue WHERE download_id = ?",
(download_id,),
).fetchone()
if row is None:
return False
max_row = conn.execute(
"SELECT MAX(priority) AS mx FROM download_queue"
).fetchone()
max_priority: int = max_row["mx"] if max_row["mx"] is not None else 0
conn.execute(
"UPDATE download_queue SET priority = ? WHERE download_id = ?",
(max_priority + 1, download_id),
)
conn.commit()
return True
async def move_to_end(self, download_id: str) -> bool:
"""Move an item to the end of the queue (lowest priority).
Returns ``True`` if the item was found and updated.
"""
async with self._lock:
conn = self._get_conn()
row = conn.execute(
"SELECT priority FROM download_queue WHERE download_id = ?",
(download_id,),
).fetchone()
if row is None:
return False
min_row = conn.execute(
"SELECT MIN(priority) AS mn FROM download_queue"
).fetchone()
min_priority: int = min_row["mn"] if min_row["mn"] is not None else 0
conn.execute(
"UPDATE download_queue SET priority = ? WHERE download_id = ?",
(min_priority - 1, download_id),
)
conn.commit()
return True
async def clear_queue(self, status_filter: Optional[str] = None) -> int:
"""Remove items from the queue.
When *status_filter* is provided only items with that status are
deleted. Returns the number of deleted rows.
"""
async with self._lock:
conn = self._get_conn()
if status_filter is not None:
cursor = conn.execute(
"DELETE FROM download_queue WHERE status = ?",
(status_filter,),
)
else:
cursor = conn.execute("DELETE FROM download_queue")
conn.commit()
return cursor.rowcount
async def complete_download(
self,
download_id: str,
status: str = "completed",
error: Optional[str] = None,
file_path: Optional[str] = None,
bytes_downloaded: int = 0,
total_bytes: Optional[int] = None,
) -> Optional[dict[str, Any]]:
"""Atomically move a download from the queue into the history table.
Looks up the queue record by ``download_id``, deletes it from the
queue, and inserts a corresponding history entry with the given
terminal status (``completed``, ``failed``, or ``canceled``).
Returns the original queue record (before deletion) on success,
or ``None`` if the download was not found in the queue.
"""
async with self._lock:
conn = self._get_conn()
row = conn.execute(
"SELECT * FROM download_queue WHERE download_id = ?",
(download_id,),
).fetchone()
if row is None:
return None
now = time.time()
conn.execute(
"DELETE FROM download_queue WHERE download_id = ?",
(download_id,),
)
conn.execute(
"""
INSERT INTO download_history (
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, status, error, file_path,
bytes_downloaded, total_bytes, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
row["download_id"],
row["model_id"],
row["model_version_id"],
row["model_name"],
row["version_name"],
row["thumbnail_url"],
status,
error,
file_path,
bytes_downloaded,
total_bytes,
now,
),
)
conn.commit()
return dict(row)
async def pop_next_download(self) -> Optional[dict[str, Any]]:
"""Atomically fetch and mark the next queued item as ``downloading``.
The item with the highest priority (and earliest ``added_at``
among ties) whose status is ``'queued'`` is selected, set to
``'downloading'``, and returned as a dict. Returns ``None`` if
the queue is empty.
"""
async with self._lock:
conn = self._get_conn()
row = conn.execute(
"""
SELECT * FROM download_queue
WHERE status = 'queued'
ORDER BY priority DESC, added_at ASC
LIMIT 1
"""
).fetchone()
if row is None:
return None
download_id = row["download_id"]
now = time.time()
conn.execute(
"UPDATE download_queue SET status = 'downloading', "
"started_at = COALESCE(started_at, ?) "
"WHERE download_id = ?",
(now, download_id),
)
conn.commit()
updated = conn.execute(
"SELECT * FROM download_queue WHERE download_id = ?",
(download_id,),
).fetchone()
return dict(updated) if updated else None
# ------------------------------------------------------------------
# History methods
# ------------------------------------------------------------------
async def add_to_history(
self,
download_id: Optional[str] = None,
model_id: Optional[int] = None,
model_version_id: Optional[int] = None,
model_name: str = "",
version_name: str = "",
thumbnail_url: str = "",
status: str = "completed",
error: Optional[str] = None,
file_path: Optional[str] = None,
bytes_downloaded: int = 0,
total_bytes: Optional[int] = None,
is_already_exists: int = 0,
) -> int:
"""Insert a record into the download history.
Returns the ``id`` (AUTOINCREMENT primary key) of the newly
inserted row.
"""
now = time.time()
async with self._lock:
conn = self._get_conn()
cursor = conn.execute(
"""
INSERT INTO download_history (
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, status, error, file_path,
bytes_downloaded, total_bytes, completed_at, is_already_exists
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
download_id,
model_id,
model_version_id,
model_name,
version_name,
thumbnail_url,
status,
error,
file_path,
bytes_downloaded,
total_bytes,
now,
is_already_exists,
),
)
conn.commit()
return cursor.lastrowid or 0
async def get_history(
self,
limit: int = 50,
offset: int = 0,
status_filter: Optional[str] = None,
) -> dict[str, Any]:
"""Return a page of download history entries.
Returns a dict with keys ``items``, ``total``, ``limit``, and
``offset``.
"""
async with self._lock:
conn = self._get_conn()
if status_filter is not None:
count_row = conn.execute(
"SELECT COUNT(*) AS cnt FROM download_history WHERE status = ?",
(status_filter,),
).fetchone()
rows = conn.execute(
"SELECT * FROM download_history WHERE status = ? "
"ORDER BY completed_at DESC LIMIT ? OFFSET ?",
(status_filter, limit, offset),
).fetchall()
else:
count_row = conn.execute(
"SELECT COUNT(*) AS cnt FROM download_history"
).fetchone()
rows = conn.execute(
"SELECT * FROM download_history "
"ORDER BY completed_at DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return {
"items": [dict(row) for row in rows],
"total": count_row["cnt"] if count_row else 0,
"limit": limit,
"offset": offset,
}
async def delete_history_item(self, id: int) -> bool:
"""Delete a single history entry by its *id*.
Returns ``True`` if a row was deleted.
"""
async with self._lock:
conn = self._get_conn()
cursor = conn.execute(
"DELETE FROM download_history WHERE id = ?",
(id,),
)
conn.commit()
return cursor.rowcount > 0
async def clear_history(
self,
status_filter: Optional[str] = None,
before_timestamp: Optional[float] = None,
) -> int:
"""Remove history entries matching the optional filters.
Both ``status_filter`` and ``before_timestamp`` can be combined
(AND logic). Returns the number of deleted rows.
"""
async with self._lock:
conn = self._get_conn()
clauses: list[str] = []
params: list[Any] = []
if status_filter is not None:
clauses.append("status = ?")
params.append(status_filter)
if before_timestamp is not None:
clauses.append("completed_at < ?")
params.append(before_timestamp)
where = ""
if clauses:
where = " WHERE " + " AND ".join(clauses)
cursor = conn.execute(
f"DELETE FROM download_history{where}",
params,
)
conn.commit()
return cursor.rowcount
async def get_history_count(self, status_filter: Optional[str] = None) -> int:
"""Return the number of history entries, optionally filtered by status."""
async with self._lock:
conn = self._get_conn()
if status_filter is not None:
row = conn.execute(
"SELECT COUNT(*) AS cnt FROM download_history WHERE status = ?",
(status_filter,),
).fetchone()
else:
row = conn.execute(
"SELECT COUNT(*) AS cnt FROM download_history"
).fetchone()
return row["cnt"] if row else 0
# ------------------------------------------------------------------
# Retry
# ------------------------------------------------------------------
async def retry_from_history(self, item_id: int) -> Optional[dict[str, Any]]:
"""Re-queue a failed or canceled download from history.
Looks up the history record by its primary key. If the status is
``failed`` or ``canceled`` a new queue entry is created with the
same model metadata and a fresh download id.
"""
async with self._lock:
conn = self._get_conn()
row = conn.execute(
"SELECT * FROM download_history WHERE id = ?",
(item_id,),
).fetchone()
if row is None:
return None
status = str(row["status"])
if status not in ("failed", "canceled"):
return None
import uuid
new_id = str(uuid.uuid4())
now = time.time()
conn.execute(
"""
INSERT INTO download_queue (
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, source, file_params,
status, priority, added_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
""",
(
new_id,
row["model_id"],
row["model_version_id"],
row["model_name"],
row["version_name"],
row["thumbnail_url"],
"retry",
now,
),
)
conn.commit()
queued = conn.execute(
"SELECT * FROM download_queue WHERE download_id = ?",
(new_id,),
).fetchone()
return dict(queued) if queued else None
async def retry_all_failed(self) -> int:
"""Re-queue all failed and canceled downloads from history.
Returns the number of items that were re-queued.
"""
async with self._lock:
conn = self._get_conn()
rows = conn.execute(
"SELECT * FROM download_history WHERE status IN ('failed', 'canceled')"
).fetchall()
if not rows:
return 0
import uuid
now = time.time()
count = 0
for row in rows:
new_id = str(uuid.uuid4())
conn.execute(
"""
INSERT INTO download_queue (
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, source, file_params,
status, priority, added_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
""",
(
new_id,
row["model_id"],
row["model_version_id"],
row["model_name"],
row["version_name"],
row["thumbnail_url"],
"retry",
now,
),
)
count += 1
conn.commit()
return count
# ------------------------------------------------------------------
# Stats
# ------------------------------------------------------------------
async def get_stats(self) -> dict[str, int]:
"""Return aggregate counts across both tables.
Returns a dict with keys ``queued``, ``downloading``, ``paused``
(all from the queue table) and ``completed``, ``failed``,
``canceled`` (all from the history table).
"""
async with self._lock:
conn = self._get_conn()
queue_rows = conn.execute(
"SELECT status, COUNT(*) AS cnt FROM download_queue GROUP BY status"
).fetchall()
queue_stats: dict[str, int] = {}
for row in queue_rows:
queue_stats[str(row["status"])] = row["cnt"]
history_rows = conn.execute(
"SELECT status, COUNT(*) AS cnt FROM download_history GROUP BY status"
).fetchall()
history_stats: dict[str, int] = {}
for row in history_rows:
history_stats[str(row["status"])] = row["cnt"]
return {
"queued": queue_stats.get("queued", 0),
"downloading": queue_stats.get("downloading", 0),
"paused": queue_stats.get("paused", 0),
"completed": history_stats.get("completed", 0),
"failed": history_stats.get("failed", 0),
"canceled": history_stats.get("canceled", 0),
}

View File

@@ -96,6 +96,21 @@ class DownloadedVersionHistoryService:
def get_database_path(self) -> str: def get_database_path(self) -> str:
return self._db_path return self._db_path
def close(self) -> None:
"""Close the persistent SQLite connection, if open.
This is called before plugin update operations to release the
database file lock on Windows, allowing ``shutil.rmtree()`` to
succeed when the cache resides inside the plugin directory.
"""
if self._conn is not None:
try:
self._conn.close()
except Exception:
pass
finally:
self._conn = None
def _get_active_library_name(self) -> str | None: def _get_active_library_name(self) -> str | None:
try: try:
value = self._settings.get_active_library_name() value = self._settings.get_active_library_name()
@@ -206,7 +221,7 @@ class DownloadedVersionHistoryService:
) )
conn.commit() 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_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id) normalized_version_id = _normalize_int(version_id)
if normalized_type is None or normalized_version_id is None: if normalized_type is None or normalized_version_id is None:

View File

@@ -13,6 +13,7 @@ This module provides a centralized download service with:
import os import os
import logging import logging
import asyncio import asyncio
import ssl
import aiohttp import aiohttp
from collections import deque from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
@@ -31,6 +32,20 @@ from .errors import RateLimitError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def is_ssl_cert_verify_error(exc: BaseException) -> bool:
"""Check if an exception represents an SSL certificate verification failure.
Matches ``ssl.SSLCertVerificationError``, ``aiohttp.ClientConnectorCertificateError``
(which wraps the former), and falls back to the standard OpenSSL error text.
"""
if isinstance(exc, ssl.SSLCertVerificationError):
return True
cert_error = getattr(exc, "certificate_error", None)
if isinstance(cert_error, ssl.SSLCertVerificationError):
return True
return "CERTIFICATE_VERIFY_FAILED" in str(exc)
@dataclass(frozen=True) @dataclass(frozen=True)
class DownloadProgress: class DownloadProgress:
"""Snapshot of a download transfer at a moment in time.""" """Snapshot of a download transfer at a moment in time."""
@@ -265,9 +280,22 @@ class Downloader:
logger.debug( logger.debug(
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment." "Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
) )
# Build SSL context: prefer certifi's CA bundle for broader
# CA coverage across different Python environments (especially
# embedded/compatibility Python builds).
try:
import certifi # type: ignore[import-untyped]
ca_path = certifi.where()
ssl_context = ssl.create_default_context(cafile=ca_path)
logger.debug("SSL: using certifi CA bundle at %s", ca_path)
except (ImportError, FileNotFoundError, ValueError, OSError):
ssl_context = ssl.create_default_context()
logger.debug("SSL: certifi unavailable; using system default CA bundle")
# Optimize TCP connection parameters # Optimize TCP connection parameters
connector = aiohttp.TCPConnector( connector = aiohttp.TCPConnector(
ssl=True, ssl=ssl_context,
limit=8, # Concurrent connections limit=8, # Concurrent connections
ttl_dns_cache=300, # DNS cache timeout ttl_dns_cache=300, # DNS cache timeout
force_close=False, # Keep connections for reuse force_close=False, # Keep connections for reuse
@@ -736,6 +764,17 @@ class Downloader:
DownloadRestartRequested, DownloadRestartRequested,
) as e: ) as e:
retry_count += 1 retry_count += 1
if is_ssl_cert_verify_error(e):
logger.error(
"SSL certificate verification failed when connecting to %s. "
"This is usually caused by an outdated CA certificate bundle "
"in the Python environment. Recommended fixes:\n"
" 1. pip install --upgrade certifi\n"
" 2. pip install pip-system-certs",
url,
)
logger.warning( logger.warning(
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}" f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
) )

View File

@@ -3,6 +3,7 @@ import logging
from typing import Dict from typing import Dict
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags
from ..utils.models import EmbeddingMetadata from ..utils.models import EmbeddingMetadata
from ..config import config from ..config import config
@@ -45,7 +46,8 @@ class EmbeddingService(BaseModelService):
"exclude": bool(embedding_data.get("exclude", False)), "exclude": bool(embedding_data.get("exclude", False)),
"update_available": bool(embedding_data.get("update_available", False)), "update_available": bool(embedding_data.get("update_available", False)),
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", 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: 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 .base_model_service import BaseModelService
from .model_query import resolve_sub_type from .model_query import resolve_sub_type
from .auto_tag_service import extract_auto_tags
from ..utils.models import LoraMetadata from ..utils.models import LoraMetadata
from ..config import config from ..config import config
@@ -57,6 +58,7 @@ class LoraService(BaseModelService):
"civitai": self.filter_civitai_data( "civitai": self.filter_civitai_data(
lora_data.get("civitai", {}), minimal=True 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]: 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.""" """Return cached raw metadata for a LoRA matching the given filename."""
cache = await self.scanner.get_cached_data(force_refresh=False) 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 []: 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 lora
return None return None
@@ -399,7 +416,10 @@ class LoraService(BaseModelService):
locked_loras = locked_loras[:target_count] locked_loras = locked_loras[:target_count]
# Filter out locked LoRAs from available pool # 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 = [ available_pool = [
l for l in available_loras if l["file_name"] not in locked_names l for l in available_loras if l["file_name"] not in locked_names
] ]
@@ -454,7 +474,7 @@ class LoraService(BaseModelService):
result_loras.append( 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, "strength": model_str,
"clipStrength": clip_str, "clipStrength": clip_str,
"active": True, "active": True,
@@ -670,8 +690,9 @@ class LoraService(BaseModelService):
# Return minimal data needed for cycling # Return minimal data needed for cycling
return [ 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"]), "model_name": lora.get("model_name", lora["file_name"]),
"folder": lora.get("folder", ""),
} }
for lora in available_loras for lora in available_loras
] ]

View File

@@ -7,6 +7,7 @@ class ModelHashIndex:
def __init__(self): def __init__(self):
self._hash_to_path: Dict[str, str] = {} self._hash_to_path: Dict[str, str] = {}
self._filename_to_hash: Dict[str, str] = {} self._filename_to_hash: Dict[str, str] = {}
self._autov2_to_path: Dict[str, str] = {}
# New data structures for tracking duplicates # New data structures for tracking duplicates
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
@@ -63,6 +64,9 @@ class ModelHashIndex:
# Add new mappings # Add new mappings
self._hash_to_path[sha256] = file_path self._hash_to_path[sha256] = file_path
self._filename_to_hash[filename] = sha256 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: def _get_filename_from_path(self, file_path: str) -> str:
"""Extract filename without extension from path""" """Extract filename without extension from path"""
@@ -158,6 +162,11 @@ class ModelHashIndex:
if filename in self._filename_to_hash: if filename in self._filename_to_hash:
del self._filename_to_hash[filename] 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: def remove_by_hash(self, sha256: str) -> None:
"""Remove entry by hash""" """Remove entry by hash"""
sha256 = sha256.lower() sha256 = sha256.lower()
@@ -177,6 +186,10 @@ class ModelHashIndex:
# Remove hash-to-path mapping # Remove hash-to-path mapping
del self._hash_to_path[sha256] 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 # Update filename-to-hash and duplicate filenames for all paths
for path_to_remove in paths_to_remove: for path_to_remove in paths_to_remove:
fname = self._get_filename_from_path(path_to_remove) fname = self._get_filename_from_path(path_to_remove)
@@ -195,13 +208,24 @@ class ModelHashIndex:
# If only one entry remains, it's no longer a duplicate # If only one entry remains, it's no longer a duplicate
del self._duplicate_filenames[fname] del self._duplicate_filenames[fname]
def has_hash(self, sha256: str) -> bool: def has_hash(self, hash_value: str) -> bool:
"""Check if hash exists in index""" """Check if hash exists in index (SHA256 or AutoV2)"""
return sha256.lower() in self._hash_to_path 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, sha256: str) -> Optional[str]: def get_path(self, hash_value: str) -> Optional[str]:
"""Get file path for a hash""" """Get file path for a hash (SHA256 or AutoV2)"""
return self._hash_to_path.get(sha256.lower()) 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]: def get_hash(self, file_path: str) -> Optional[str]:
"""Get hash for a file path""" """Get hash for a file path"""
@@ -209,13 +233,16 @@ class ModelHashIndex:
return self._filename_to_hash.get(filename) return self._filename_to_hash.get(filename)
def get_hash_by_filename(self, filename: str) -> Optional[str]: 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) return self._filename_to_hash.get(filename)
def clear(self) -> None: def clear(self) -> None:
"""Clear all entries""" """Clear all entries"""
self._hash_to_path.clear() self._hash_to_path.clear()
self._filename_to_hash.clear() self._filename_to_hash.clear()
self._autov2_to_path.clear()
self._duplicate_hashes.clear() self._duplicate_hashes.clear()
self._duplicate_filenames.clear() self._duplicate_filenames.clear()

View File

@@ -111,6 +111,11 @@ class ModelLifecycleService:
self._scanner._hash_index.remove_by_path(file_path) self._scanner._hash_index.remove_by_path(file_path)
await self._sync_update_for_model(model_id) 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} return {"success": True, "deleted_files": deleted_files}
@staticmethod @staticmethod

View File

@@ -5,7 +5,7 @@ import logging
import random import random
from typing import Optional, Dict, Tuple, Any, List, Sequence from typing import Optional, Dict, Tuple, Any, List, Sequence
from .downloader import get_downloader from .downloader import get_downloader
from .errors import RateLimitError from .errors import RateLimitError, ResourceNotFoundError
try: try:
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -482,6 +482,7 @@ class FallbackMetadataProvider(ModelMetadataProvider):
return None, "Model not found" return None, "Model not found"
async def get_model_versions(self, model_id: str) -> Optional[Dict]: async def get_model_versions(self, model_id: str) -> Optional[Dict]:
not_found_confirmed = False
for provider, label in self._iter_providers(): for provider, label in self._iter_providers():
try: try:
result = await self._call_with_rate_limit( result = await self._call_with_rate_limit(
@@ -492,8 +493,24 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result: if result:
return result return result
except RateLimitError as exc: 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 exc.provider = exc.provider or label
raise exc 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: except Exception as e:
logger.debug("Provider %s failed for get_model_versions: %s", label, e) logger.debug("Provider %s failed for get_model_versions: %s", label, e)
continue continue

View File

@@ -96,6 +96,7 @@ class FilterCriteria:
folder_exclude: Optional[Sequence[str]] = None folder_exclude: Optional[Sequence[str]] = None
base_models: Optional[Sequence[str]] = None base_models: Optional[Sequence[str]] = None
tags: Optional[Dict[str, str]] = None tags: Optional[Dict[str, str]] = None
auto_tags: Optional[Dict[str, str]] = None
favorites_only: bool = False favorites_only: bool = False
search_options: Optional[Dict[str, Any]] = None search_options: Optional[Dict[str, Any]] = None
model_types: Optional[Sequence[str]] = None model_types: Optional[Sequence[str]] = None
@@ -359,10 +360,37 @@ class ModelFilterSet:
] ]
model_types_duration = time.perf_counter() - t0 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 duration = time.perf_counter() - overall_start
if duration > 0.1: # Only log if it's potentially slow if duration > 0.1: # Only log if it's potentially slow
logger.debug( 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", "Count: %d -> %d",
duration, duration,
sfw_duration, sfw_duration,
@@ -371,6 +399,7 @@ class ModelFilterSet:
base_models_duration, base_models_duration,
tags_duration, tags_duration,
model_types_duration, model_types_duration,
auto_tags_duration,
initial_count, initial_count,
len(items), 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 ..utils.models import BaseModelMetadata
from ..config import config 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.metadata_manager import MetadataManager
from ..utils.civitai_utils import resolve_license_info from ..utils.civitai_utils import resolve_license_info
from .model_cache import ModelCache from .model_cache import ModelCache
@@ -1067,6 +1067,19 @@ class ModelScanner:
model_data = self._build_cache_entry(metadata, folder=normalized_folder) 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 # Skip excluded models
if model_data.get('exclude', False): if model_data.get('exclude', False):
excluded_models.append(model_data['file_path']) excluded_models.append(model_data['file_path'])
@@ -1101,7 +1114,15 @@ class ModelScanner:
def _log_duplicate_filename_summary(self) -> None: def _log_duplicate_filename_summary(self) -> None:
"""Log a batched summary of duplicate filename conflicts once per scan.""" """Log a batched summary of duplicate filename conflicts once per scan."""
if self._hash_index is None: # 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 return
duplicates = self._hash_index.get_duplicate_filenames() duplicates = self._hash_index.get_duplicate_filenames()
@@ -1473,6 +1494,15 @@ class ModelScanner:
file_path_override=normalized_new_path, 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: if recalculate_type:
cache_entry = self.adjust_cached_entry(cache_entry) cache_entry = self.adjust_cached_entry(cache_entry)
@@ -1573,11 +1603,38 @@ class ModelScanner:
try: try:
cache = await self.get_cached_data() 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: 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 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: except Exception as e:
logger.error(f"Error getting model info by name: {e}", exc_info=True) logger.error(f"Error getting model info by name: {e}", exc_info=True)
return None return None

View File

@@ -689,6 +689,7 @@ class ModelUpdateService:
*, *,
force_refresh: bool = False, force_refresh: bool = False,
target_model_ids: Optional[Sequence[int]] = None, target_model_ids: Optional[Sequence[int]] = None,
folder_path: Optional[str] = None,
) -> Dict[int, ModelUpdateRecord]: ) -> Dict[int, ModelUpdateRecord]:
"""Refresh update information for every model present in the cache.""" """Refresh update information for every model present in the cache."""
scanner.reset_cancellation() scanner.reset_cancellation()
@@ -703,6 +704,7 @@ class ModelUpdateService:
local_versions = await self._collect_local_versions( local_versions = await self._collect_local_versions(
scanner, scanner,
target_model_ids=target_filter, target_model_ids=target_filter,
folder_path=folder_path,
) )
total_models = len(local_versions) total_models = len(local_versions)
if total_models == 0: if total_models == 0:
@@ -1000,12 +1002,11 @@ class ModelUpdateService:
fallback_error_message = str(exc) or "resource not found" fallback_error_message = str(exc) or "resource not found"
mark_model_as_ignored = True mark_model_as_ignored = True
except Exception as exc: # pragma: no cover - defensive log except Exception as exc: # pragma: no cover - defensive log
logger.error( logger.warning(
"Failed to fetch versions for model %s (%s): %s", "Failed to fetch versions for model %s (%s): %s",
model_id, model_id,
model_type, model_type,
exc, exc,
exc_info=True,
) )
fallback_error_message = str(exc) fallback_error_message = str(exc)
if response is not None: if response is not None:
@@ -1277,6 +1278,7 @@ class ModelUpdateService:
scanner, scanner,
*, *,
target_model_ids: Optional[Sequence[int]] = None, target_model_ids: Optional[Sequence[int]] = None,
folder_path: Optional[str] = None,
) -> Dict[int, List[int]]: ) -> Dict[int, List[int]]:
cache = await scanner.get_cached_data() cache = await scanner.get_cached_data()
mapping: Dict[int, set[int]] = {} mapping: Dict[int, set[int]] = {}
@@ -1289,7 +1291,19 @@ class ModelUpdateService:
if not target_set: if not target_set:
return {} return {}
normalized_folder = None
if folder_path is not None:
normalized_folder = folder_path.replace("\\", "/").strip("/")
for item in cache.raw_data: for item in cache.raw_data:
# Apply folder filter first (cheapest check)
if normalized_folder is not None:
if not isinstance(item, dict):
continue
item_folder = (item.get("folder") or "").replace("\\", "/").strip("/")
if item_folder != normalized_folder and not item_folder.startswith(normalized_folder + "/"):
continue
civitai = item.get("civitai") if isinstance(item, dict) else None civitai = item.get("civitai") if isinstance(item, dict) else None
if not isinstance(civitai, dict): if not isinstance(civitai, dict):
continue continue

View File

@@ -38,6 +38,7 @@ class PersistentRecipeCache:
"json_path", "json_path",
"title", "title",
"folder", "folder",
"source_path",
"base_model", "base_model",
"fingerprint", "fingerprint",
"created_date", "created_date",
@@ -334,6 +335,7 @@ class PersistentRecipeCache:
json_path TEXT, json_path TEXT,
title TEXT, title TEXT,
folder TEXT, folder TEXT,
source_path TEXT,
base_model TEXT, base_model TEXT,
fingerprint TEXT, fingerprint TEXT,
created_date REAL, 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() conn.commit()
self._schema_initialized = True self._schema_initialized = True
except Exception as exc: except Exception as exc:
@@ -406,6 +415,7 @@ class PersistentRecipeCache:
json_path, json_path,
recipe.get("title"), recipe.get("title"),
recipe.get("folder"), recipe.get("folder"),
recipe.get("source_path"),
recipe.get("base_model"), recipe.get("base_model"),
recipe.get("fingerprint"), recipe.get("fingerprint"),
float(recipe.get("created_date") or 0.0), float(recipe.get("created_date") or 0.0),
@@ -456,6 +466,7 @@ class PersistentRecipeCache:
"file_path": row["file_path"] or "", "file_path": row["file_path"] or "",
"title": row["title"] or "", "title": row["title"] or "",
"folder": row["folder"] or "", "folder": row["folder"] or "",
"source_path": row["source_path"] or "",
"base_model": row["base_model"] or "", "base_model": row["base_model"] or "",
"fingerprint": row["fingerprint"] or "", "fingerprint": row["fingerprint"] or "",
"created_date": row["created_date"] or 0.0, "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 cls._instance._civitai_client = None # Will be lazily initialized
return cls._instance return cls._instance
REPAIR_VERSION = 3 REPAIR_VERSION = 4
def __init__( def __init__(
self, self,
@@ -292,6 +292,32 @@ class RecipeScanner:
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION: if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
return False 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? # 2. Identification: Is repair needed?
has_checkpoint = ( has_checkpoint = (
"checkpoint" in recipe "checkpoint" in recipe
@@ -504,6 +530,9 @@ class RecipeScanner:
self._cache.raw_data = recipes self._cache.raw_data = recipes
self._update_folder_metadata(self._cache) self._update_folder_metadata(self._cache)
self._sort_cache_sync() 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 return self._cache
else: else:
# Partial update: some files changed # Partial update: some files changed
@@ -514,6 +543,8 @@ class RecipeScanner:
self._cache.raw_data = recipes self._cache.raw_data = recipes
self._update_folder_metadata(self._cache) self._update_folder_metadata(self._cache)
self._sort_cache_sync() 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 # Persist updated cache
self._persistent_cache.save_cache(recipes, json_paths) self._persistent_cache.save_cache(recipes, json_paths)
return self._cache return self._cache
@@ -642,6 +673,34 @@ class RecipeScanner:
return recipes, changed, json_paths 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( def _full_directory_scan_sync(
self, recipes_dir: str self, recipes_dir: str
) -> Tuple[List[Dict], Dict[str, str]]: ) -> Tuple[List[Dict], Dict[str, str]]:
@@ -2484,6 +2543,7 @@ class RecipeScanner:
continue continue
file_name = None file_name = None
folder = ""
hash_value = (lora.get("hash") or "").lower() hash_value = (lora.get("hash") or "").lower()
if ( if (
hash_value hash_value
@@ -2493,6 +2553,11 @@ class RecipeScanner:
file_path = self._lora_scanner._hash_index.get_path(hash_value) file_path = self._lora_scanner._hash_index.get_path(hash_value)
if file_path: if file_path:
file_name = os.path.splitext(os.path.basename(file_path))[0] 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: if not file_name and lora.get("modelVersionId") and lora_cache is not None:
for cached_lora in getattr(lora_cache, "raw_data", []): 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))[ file_name = os.path.splitext(os.path.basename(cached_path))[
0 0
] ]
folder = cached_lora.get("folder", "")
break break
if not file_name: if not file_name:
file_name = lora.get("file_name", "unknown-lora") 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) 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 return syntax_parts

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import base64 import base64
import io import io
import os import os
@@ -14,6 +15,7 @@ from PIL import Image
from ...utils.utils import calculate_recipe_fingerprint from ...utils.utils import calculate_recipe_fingerprint
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
from ...recipes.enrichment import RecipeEnricher
from .errors import ( from .errors import (
RecipeDownloadError, RecipeDownloadError,
RecipeNotFoundError, RecipeNotFoundError,
@@ -170,9 +172,29 @@ class RecipeAnalysisService:
await self._download_image(url, temp_path) await self._download_image(url, temp_path)
if metadata is None and not is_video: 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( if not metadata and civitai_image_id and image_info:
original_url = image_info.get("url")
if original_url:
self._logger.debug(
"Optimized image lacks embedded metadata, "
"falling back to original image: %s",
original_url,
)
orig_temp_path = self._create_temp_path(suffix=".png")
try:
await self._download_image(original_url, orig_temp_path)
metadata = await asyncio.to_thread(
self._exif_utils.extract_image_metadata,
orig_temp_path,
)
finally:
self._safe_cleanup(orig_temp_path)
result = await self._parse_metadata(
metadata or {}, metadata or {},
recipe_scanner=recipe_scanner, recipe_scanner=recipe_scanner,
image_path=temp_path, image_path=temp_path,
@@ -180,6 +202,37 @@ class RecipeAnalysisService:
is_video=is_video, is_video=is_video,
extension=extension, 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: finally:
if temp_path: if temp_path:
self._safe_cleanup(temp_path) self._safe_cleanup(temp_path)
@@ -199,7 +252,9 @@ class RecipeAnalysisService:
if not os.path.isfile(normalized_path): if not os.path.isfile(normalized_path):
raise RecipeNotFoundError("File not found") 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: if not metadata:
return self._metadata_not_found_response(normalized_path) return self._metadata_not_found_response(normalized_path)

View File

@@ -49,8 +49,18 @@ class RecipePersistenceService:
tags: Iterable[str], tags: Iterable[str],
metadata: Optional[dict[str, Any]], metadata: Optional[dict[str, Any]],
extension: str | None = None, extension: str | None = None,
recipe_id: str | None = None,
target_dir: str | None = None,
) -> PersistenceResult: ) -> PersistenceResult:
"""Persist a user uploaded recipe.""" """Persist a user uploaded recipe.
Args:
recipe_id: If provided, reuse this ID instead of generating a new
UUID. Used by re-import to preserve the original recipe identity.
target_dir: If provided, save recipe files to this directory instead
of the default recipes_dir. Used by re-import to preserve the
original folder location.
"""
missing_fields = [] missing_fields = []
if not name: if not name:
@@ -63,10 +73,10 @@ class RecipePersistenceService:
) )
resolved_image_bytes = self._resolve_image_bytes(image_bytes, image_base64) resolved_image_bytes = self._resolve_image_bytes(image_bytes, image_base64)
recipes_dir = recipe_scanner.recipes_dir recipes_dir = target_dir or recipe_scanner.recipes_dir
os.makedirs(recipes_dir, exist_ok=True) os.makedirs(recipes_dir, exist_ok=True)
recipe_id = str(uuid.uuid4()) recipe_id = recipe_id or str(uuid.uuid4())
# Handle video formats by bypassing optimization and metadata embedding # Handle video formats by bypassing optimization and metadata embedding
is_video = extension in [".mp4", ".webm"] is_video = extension in [".mp4", ".webm"]
@@ -115,6 +125,22 @@ class RecipePersistenceService:
if metadata.get("source_path"): if metadata.get("source_path"):
recipe_data["source_path"] = metadata.get("source_path") recipe_data["source_path"] = metadata.get("source_path")
nsfw_level = metadata.get("preview_nsfw_level")
if nsfw_level is not None and isinstance(nsfw_level, int):
recipe_data["preview_nsfw_level"] = nsfw_level
# Compute recipe folder relative to recipes root, mirroring
# RecipeScanner._calculate_folder() which is only called during scan/load.
if recipe_scanner.recipes_dir:
recipe_file_dir = os.path.dirname(normalized_image_path)
try:
relative_folder = os.path.relpath(recipe_file_dir, recipe_scanner.recipes_dir)
if relative_folder in (".", ""):
relative_folder = ""
recipe_data["folder"] = relative_folder.replace(os.path.sep, "/")
except Exception:
recipe_data["folder"] = ""
json_filename = f"{recipe_id}.recipe.json" json_filename = f"{recipe_id}.recipe.json"
json_path = os.path.join(recipes_dir, json_filename) json_path = os.path.join(recipes_dir, json_filename)
json_path = os.path.normpath(json_path) json_path = os.path.normpath(json_path)

View File

@@ -188,6 +188,25 @@ class ServiceRegistry:
logger.debug(f"Created and registered {service_name}") logger.debug(f"Created and registered {service_name}")
return service return service
@classmethod
async def get_download_queue_service(cls):
"""Get or create the download queue service."""
service_name = "download_queue_service"
if service_name in cls._services:
return cls._services[service_name]
async with cls._get_lock(service_name):
if service_name in cls._services:
return cls._services[service_name]
from .download_queue_service import DownloadQueueService
service = await DownloadQueueService.get_instance()
cls._services[service_name] = service
logger.debug(f"Created and registered {service_name}")
return service
@classmethod @classmethod
async def get_backup_service(cls): async def get_backup_service(cls):
"""Get or create the backup service.""" """Get or create the backup service."""

View File

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

View File

@@ -4,7 +4,9 @@ from __future__ import annotations
import os 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: class TagUpdateService:
@@ -20,9 +22,8 @@ class TagUpdateService:
new_tags: Sequence[str], new_tags: Sequence[str],
metadata_loader: Callable[[str], Awaitable[Dict[str, object]]], metadata_loader: Callable[[str], Awaitable[Dict[str, object]]],
update_cache: Callable[[str, str, Dict[str, object]], Awaitable[bool]], update_cache: Callable[[str, str, Dict[str, object]], Awaitable[bool]],
) -> List[str]: ) -> Tuple[List[str], List[str]]:
"""Add tags to a metadata entry while keeping case-insensitive uniqueness.""" """Add tags to a metadata entry and return updated tags and auto_tags."""
base, _ = os.path.splitext(file_path) base, _ = os.path.splitext(file_path)
metadata_path = f"{base}.metadata.json" metadata_path = f"{base}.metadata.json"
metadata = await metadata_loader(metadata_path) metadata = await metadata_loader(metadata_path)
@@ -44,5 +45,6 @@ class TagUpdateService:
await self._metadata_manager.save_metadata(file_path, metadata) await self._metadata_manager.save_metadata(file_path, metadata)
await update_cache(file_path, 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 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_CIVITAI_PAGE_HOST = "civitai.com"
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",) _DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
_LICENSE_DEFAULTS: Dict[str, Any] = { _LICENSE_DEFAULTS: Dict[str, Any] = {
@@ -66,6 +66,46 @@ def build_civitai_model_page_url(
return None return None
_RE_CDN_IMAGE_ID = re.compile(r"/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)")
def extract_civitai_image_id_from_cdn_url(url: str | None) -> str | None:
"""Extract the numeric image ID from a Cloudflare CDN image URL.
CivitAI image CDN URLs follow the pattern::
https://image.civitai.com/{cf_uuid}/{params}/{image_id}.{ext}
The image database ID is always the last path segment (minus extension)
because ``getEdgeUrl(…, name=id.toString())`` embeds it explicitly
in the model-versions REST API response.
"""
if not url:
return None
match = _RE_CDN_IMAGE_ID.search(url)
return match.group(1) if match else None
def build_civitai_image_page_url(
image_id: str | int | None,
*,
host: str | None = None,
) -> str | None:
"""Build a Civitai image page URL.
Returns something like ``https://civitai.com/images/12345``.
The host is resolved through :func:`normalize_civitai_page_host` and
therefore respects the user's ``civitai_host`` setting.
"""
if not image_id:
return None
normalized_host = normalize_civitai_page_host(host)
normalized_id = str(image_id).strip()
if not normalized_id:
return None
return urlunparse(("https", normalized_host, f"/images/{normalized_id}", "", "", ""))
def _parse_supported_civitai_page_url(url: str | None): def _parse_supported_civitai_page_url(url: str | None):
if not url: if not url:
return None return None
@@ -239,9 +279,9 @@ def _resolve_commercial_bits(values: Sequence[str]) -> int:
normalized_values.add(normalized) normalized_values.add(normalized)
has_sell = "sell" in normalized_values has_sell = "sell" in normalized_values
has_rent = has_sell or "rent" in normalized_values has_rent = "rent" in normalized_values
has_rentcivit = has_rent or "rentcivit" in normalized_values has_rentcivit = "rentcivit" in normalized_values
has_image = has_sell or "image" in normalized_values has_image = "image" in normalized_values
commercial_bits = ( commercial_bits = (
(1 if has_sell else 0) << 3 (1 if has_sell else 0) << 3
@@ -328,8 +368,10 @@ def rewrite_preview_url(
__all__ = [ __all__ = [
"build_civitai_image_page_url",
"build_license_flags", "build_license_flags",
"extract_civitai_image_id", "extract_civitai_image_id",
"extract_civitai_image_id_from_cdn_url",
"extract_civitai_page_host", "extract_civitai_page_host",
"extract_civitai_model_url_parts", "extract_civitai_model_url_parts",
"is_supported_civitai_page_host", "is_supported_civitai_page_host",

View File

@@ -101,8 +101,34 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
DIFFUSION_MODEL_BASE_MODELS = frozenset( DIFFUSION_MODEL_BASE_MODELS = frozenset(
[ [
"Anima", "Anima",
"ZImageTurbo", # Flux series — DiT architecture, loaded via UNETLoader in ComfyUI
"ZImageBase", "Flux.1 D",
"Flux.1 S",
"Flux.1 Krea",
"Flux.1 Kontext",
"Flux.2 D",
"Flux.2 Klein 9B",
"Flux.2 Klein 9B-base",
"Flux.2 Klein 4B",
"Flux.2 Klein 4B-base",
# Non-UNet / DiT image diffusion models
"AuraFlow",
"Chroma",
"HiDream",
"Hunyuan 1",
"Kolors",
"Lumina",
"PixArt a",
"PixArt E",
# Video diffusion models
"CogVideoX",
"Hunyuan Video",
"LTXV",
"LTXV2",
"LTXV 2.3",
"Mochi",
"SVD",
"Wan Video",
"Wan Video 1.3B t2v", "Wan Video 1.3B t2v",
"Wan Video 14B t2v", "Wan Video 14B t2v",
"Wan Video 14B i2v 480p", "Wan Video 14B i2v 480p",
@@ -112,9 +138,13 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
"Wan Video 2.2 T2V-A14B", "Wan Video 2.2 T2V-A14B",
"Wan Video 2.5 T2V", "Wan Video 2.5 T2V",
"Wan Video 2.5 I2V", "Wan Video 2.5 I2V",
"CogVideoX", # Other diffusion models
"Mochi", "Ernie",
"Ernie Turbo",
"Nucleus",
"Qwen", "Qwen",
"ZImageBase",
"ZImageTurbo",
] ]
) )

View File

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

View File

@@ -452,3 +452,111 @@ class MetadataUpdater:
except Exception as e: except Exception as e:
logger.error(f"Error parsing image metadata: {e}", exc_info=True) logger.error(f"Error parsing image metadata: {e}", exc_info=True)
return None 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

@@ -1,7 +1,10 @@
import hashlib import hashlib
import json
import logging import logging
import os import os
import struct
from typing import Any
from .constants import ( from .constants import (
CARD_PREVIEW_WIDTH, CARD_PREVIEW_WIDTH,
@@ -31,7 +34,7 @@ def _get_hash_chunk_size_bytes() -> int:
async def calculate_sha256(file_path: str) -> str: async def calculate_sha256(file_path: str) -> str:
"""Calculate SHA256 hash of a file""" """Calculate SHA256 hash of a file (full file content)."""
sha256_hash = hashlib.sha256() sha256_hash = hashlib.sha256()
chunk_size = _get_hash_chunk_size_bytes() chunk_size = _get_hash_chunk_size_bytes()
with open(file_path, "rb") as f: with open(file_path, "rb") as f:
@@ -39,6 +42,79 @@ async def calculate_sha256(file_path: str) -> str:
sha256_hash.update(byte_block) sha256_hash.update(byte_block)
return sha256_hash.hexdigest() return sha256_hash.hexdigest()
def calculate_autov2(file_path: str) -> str:
"""Calculate CivitAI AutoV2 hash.
AutoV2 is the first 10 characters of the full file SHA256.
Used by CivitAI as a shortened file identifier.
Reference: https://developer.civitai.com/site/reference/model-versions
"""
full_hash = hashlib.sha256()
chunk_size = _get_hash_chunk_size_bytes()
with open(file_path, "rb") as f:
for byte_block in iter(lambda: f.read(chunk_size), b""):
full_hash.update(byte_block)
return full_hash.hexdigest()[:10]
def read_safetensors_metadata(file_path: str) -> dict[str, Any]:
"""Read the ``__metadata__`` dict from a safetensors file header.
Safetensors file format:
- 8 bytes: header length (little-endian 64-bit)
- N bytes: UTF-8 JSON header
- The header JSON contains a ``__metadata__`` key holding arbitrary metadata.
Returns an empty dict if the file is not a valid safetensors file or has no
metadata.
"""
try:
with open(file_path, "rb") as f:
header_len_bytes = f.read(8)
if len(header_len_bytes) < 8:
return {}
header_len = struct.unpack("<Q", header_len_bytes)[0]
header_bytes = f.read(header_len)
if len(header_bytes) < header_len:
return {}
header = json.loads(header_bytes.decode("utf-8"))
return header.get("__metadata__", {})
except (OSError, json.JSONDecodeError, UnicodeDecodeError, struct.error, MemoryError, Exception):
return {}
def calculate_autov3(file_path: str) -> str | None:
"""Calculate CivitAI AutoV3 hash from a safetensors file.
AutoV3 is extracted from the safetensors file's embedded metadata, not
computed from the file bytes directly. The orchestrator reads the
``sshs_model_hash`` (kohya-ss format) or ``modelspec.hash_sha256`` field
from the safetensors header and stores the first 12 characters.
The embedded hash itself is the SHA256 of the file after skipping the
8-byte header length + JSON header (a.k.a. the addnet hash / tensor-only
hash).
Reference:
- CivitAI DB trigger: ``SUBSTRING(NEW.hash FROM 1 FOR 12)``
- https://developer.civitai.com/site/reference/model-versions
Returns ``None`` when no AutoV3 hash can be determined (e.g. the file is
not safetensors, or the metadata doesn't contain a recognised hash field).
"""
metadata = read_safetensors_metadata(file_path)
if not metadata:
return None
embedded_hash = metadata.get("sshs_model_hash") or metadata.get("modelspec.hash_sha256")
if embedded_hash and isinstance(embedded_hash, str) and len(embedded_hash) >= 12:
return embedded_hash[:12]
return None
def find_preview_file(base_name: str, dir_path: str) -> str: def find_preview_file(base_name: str, dir_path: str) -> str:
"""Find preview file for given base name in directory. """Find preview file for given base name in directory.

View File

@@ -64,6 +64,27 @@ def _build_log_file_path(settings_file: str | None, started_at: datetime) -> str
return os.path.join(log_dir, f"standalone-session-{timestamp}.log") return os.path.join(log_dir, f"standalone-session-{timestamp}.log")
_KEEP_LOG_COUNT = 3
def _prune_old_logs(log_dir: str) -> None:
"""Remove older session log files, keeping only the ``_KEEP_LOG_COUNT`` newest."""
try:
files = [
os.path.join(log_dir, name)
for name in os.listdir(log_dir)
if name.startswith("standalone-session-") and name.endswith(".log")
]
except OSError:
return
files.sort(key=os.path.getmtime, reverse=True)
for path in files[_KEEP_LOG_COUNT:]:
try:
os.remove(path)
except OSError:
pass
def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState: def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState:
global _session_state global _session_state
@@ -90,6 +111,7 @@ def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSes
file_handler.set_name(_FILE_HANDLER_NAME) file_handler.set_name(_FILE_HANDLER_NAME)
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler) root_logger.addHandler(file_handler)
_prune_old_logs(os.path.dirname(log_file_path))
_session_state = StandaloneSessionLogState( _session_state = StandaloneSessionLogState(
started_at=started_at, started_at=started_at,

View File

@@ -1,4 +1,5 @@
import os import os
import re
import json import json
import time import time
import asyncio import asyncio
@@ -9,6 +10,7 @@ from typing import Dict, Set
from ..config import config from ..config import config
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..utils.settings_paths import get_settings_dir
# Check if running in standalone mode # Check if running in standalone mode
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0" standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
@@ -16,14 +18,18 @@ standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.en
# Define constants locally to avoid dependency on conditional imports # Define constants locally to avoid dependency on conditional imports
MODELS = "models" MODELS = "models"
LORAS = "loras" LORAS = "loras"
EMBEDDINGS = "embeddings"
PROMPTS = "prompts"
if not standalone_mode: if not standalone_mode:
from ..metadata_collector.metadata_registry import MetadataRegistry from ..metadata_collector.metadata_registry import MetadataRegistry
# Import constants from metadata_collector to ensure consistency, but we have fallbacks defined above # Import constants from metadata_collector to ensure consistency, but we have fallbacks defined above
try: try:
from ..metadata_collector.constants import MODELS as _MODELS, LORAS as _LORAS from ..metadata_collector.constants import MODELS as _MODELS, LORAS as _LORAS, EMBEDDINGS as _EMBEDDINGS, PROMPTS as _PROMPTS
MODELS = _MODELS MODELS = _MODELS
LORAS = _LORAS LORAS = _LORAS
EMBEDDINGS = _EMBEDDINGS
PROMPTS = _PROMPTS
except ImportError: except ImportError:
pass # Use the local definitions pass # Use the local definitions
@@ -65,6 +71,7 @@ class UsageStats:
self.stats = { self.stats = {
"checkpoints": {}, # sha256 -> { total: count, history: { date: count } } "checkpoints": {}, # sha256 -> { total: count, history: { date: count } }
"loras": {}, # sha256 -> { total: count, history: { date: count } } "loras": {}, # sha256 -> { total: count, history: { date: count } }
"embeddings": {}, # sha256 -> { total: count, history: { date: count } }
"total_executions": 0, "total_executions": 0,
"last_save_time": 0 "last_save_time": 0
} }
@@ -77,6 +84,7 @@ class UsageStats:
# Load existing stats if available # Load existing stats if available
self._stats_file_path = self._get_stats_file_path() self._stats_file_path = self._get_stats_file_path()
self._migrate_from_old_location()
self._load_stats() self._load_stats()
# Save interval in seconds # Save interval in seconds
@@ -89,14 +97,38 @@ class UsageStats:
logger.debug("Usage statistics tracker initialized") logger.debug("Usage statistics tracker initialized")
def _get_stats_file_path(self) -> str: def _get_stats_file_path(self) -> str:
"""Get the path to the stats JSON file""" """Get the path to the stats JSON file in the settings directory."""
if not config.loras_roots or len(config.loras_roots) == 0: settings_dir = get_settings_dir(create=True)
# If no lora roots are available, we can't save stats return os.path.join(settings_dir, "stats", self.STATS_FILENAME)
# This will be handled by the caller
raise RuntimeError("No LoRA root directories configured. Cannot initialize usage statistics.")
# Use the first lora root @staticmethod
return os.path.join(config.loras_roots[0], self.STATS_FILENAME) def _get_old_stats_file_path() -> str:
"""Get the legacy stats file path in the first lora root directory."""
if not config.loras_roots or len(config.loras_roots) == 0:
return ""
return os.path.join(config.loras_roots[0], UsageStats.STATS_FILENAME)
def _migrate_from_old_location(self) -> None:
"""Migrate stats file from old location (first lora root) to new location (settings_dir/stats/)."""
new_path = self._stats_file_path
if os.path.exists(new_path):
return
old_path = self._get_old_stats_file_path()
if not old_path or not os.path.exists(old_path):
return
try:
os.makedirs(os.path.dirname(new_path), exist_ok=True)
shutil.copy2(old_path, new_path)
logger.info("Migrated usage stats from %s to %s", old_path, new_path)
try:
os.remove(old_path)
logger.info("Cleaned up old stats file: %s", old_path)
except Exception as e:
logger.warning("Failed to remove old stats file %s: %s", old_path, e)
except Exception as e:
logger.error("Failed to migrate usage stats from %s to %s: %s", old_path, new_path, e)
def _backup_old_stats(self): def _backup_old_stats(self):
"""Backup the old stats file before conversion""" """Backup the old stats file before conversion"""
@@ -115,6 +147,7 @@ class UsageStats:
new_stats = { new_stats = {
"checkpoints": {}, "checkpoints": {},
"loras": {}, "loras": {},
"embeddings": {},
"total_executions": old_stats.get("total_executions", 0), "total_executions": old_stats.get("total_executions", 0),
"last_save_time": old_stats.get("last_save_time", time.time()) "last_save_time": old_stats.get("last_save_time", time.time())
} }
@@ -142,21 +175,27 @@ class UsageStats:
} }
} }
# Convert embedding stats (if present in old format)
if "embeddings" in old_stats and isinstance(old_stats["embeddings"], dict):
for hash_id, count in old_stats["embeddings"].items():
new_stats["embeddings"][hash_id] = {
"total": count,
"history": {
today: count
}
}
logger.info("Successfully converted stats from old format to new format with history") logger.info("Successfully converted stats from old format to new format with history")
return new_stats return new_stats
def _is_old_format(self, stats): def _is_old_format(self, stats):
"""Check if the stats are in the old format (direct count values)""" """Check if the stats are in the old format (direct count values)"""
# Check if any lora or checkpoint entry is a direct number instead of an object # Check if any lora or checkpoint entry is a direct number instead of an object
if "loras" in stats and isinstance(stats["loras"], dict): for category in ("loras", "checkpoints", "embeddings"):
for hash_id, data in stats["loras"].items(): if category in stats and isinstance(stats[category], dict):
if isinstance(data, (int, float)): for hash_id, data in stats[category].items():
return True if isinstance(data, (int, float)):
return True
if "checkpoints" in stats and isinstance(stats["checkpoints"], dict):
for hash_id, data in stats["checkpoints"].items():
if isinstance(data, (int, float)):
return True
return False return False
@@ -182,6 +221,9 @@ class UsageStats:
if "loras" in loaded_stats and isinstance(loaded_stats["loras"], dict): if "loras" in loaded_stats and isinstance(loaded_stats["loras"], dict):
self.stats["loras"] = loaded_stats["loras"] self.stats["loras"] = loaded_stats["loras"]
if "embeddings" in loaded_stats and isinstance(loaded_stats["embeddings"], dict):
self.stats["embeddings"] = loaded_stats["embeddings"]
if "total_executions" in loaded_stats: if "total_executions" in loaded_stats:
self.stats["total_executions"] = loaded_stats["total_executions"] self.stats["total_executions"] = loaded_stats["total_executions"]
@@ -304,6 +346,10 @@ class UsageStats:
if LORAS in metadata and isinstance(metadata[LORAS], dict): if LORAS in metadata and isinstance(metadata[LORAS], dict):
await self._process_loras(metadata[LORAS], today) await self._process_loras(metadata[LORAS], today)
# Process embeddings — parse prompt text for embedding:name references
if PROMPTS in metadata and isinstance(metadata[PROMPTS], dict):
await self._process_embeddings(metadata[PROMPTS], today)
def _increment_usage_counter(self, category: str, stat_key: str, today_date: str) -> None: def _increment_usage_counter(self, category: str, stat_key: str, today_date: str) -> None:
"""Increment usage counters for a resolved stats key.""" """Increment usage counters for a resolved stats key."""
if stat_key not in self.stats[category]: if stat_key not in self.stats[category]:
@@ -510,6 +556,55 @@ class UsageStats:
except Exception as e: except Exception as e:
logger.error(f"Error processing LoRA usage: {e}", exc_info=True) logger.error(f"Error processing LoRA usage: {e}", exc_info=True)
@staticmethod
def _extract_embedding_names(prompt_text: str) -> set:
"""Parse embedding:name references from prompt text.
ComfyUI's SDTokenizer resolves ``embedding:<name>`` during tokenization
(see ``sd1_clip.py _try_get_embedding``). This mirrors the same pattern
to extract embedding file names from the captured prompt strings.
"""
if not prompt_text:
return set()
# Matches ``embedding:name`` where name is alphanumeric plus _ . - /
names = re.findall(r"embedding:([a-zA-Z0-9_.\-/]+)", prompt_text)
return set(names)
async def _process_embeddings(self, prompts_data, today_date):
"""Extract embedding usage from prompt texts and record it.
Iterates every prompt node's text field captured by the metadata
collector, extracts ``embedding:<name>`` references, resolves each
name to its SHA256 hash via the embedding scanner, and increments
usage counters.
"""
try:
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
if not embedding_scanner:
logger.warning("Embedding scanner not available for usage tracking")
return
seen_names = set()
for _node_id, prompt_data in prompts_data.items():
if not isinstance(prompt_data, dict):
continue
for text_field in ("text", "positive_text", "negative_text"):
text = prompt_data.get(text_field)
if isinstance(text, str):
seen_names.update(self._extract_embedding_names(text))
for emb_name in seen_names:
emb_hash = embedding_scanner.get_hash_by_filename(emb_name)
if emb_hash:
self._increment_usage_counter("embeddings", emb_hash, today_date)
else:
logger.debug(
"No hash found for embedding '%s', skipping usage tracking",
emb_name,
)
except Exception as e:
logger.error("Error processing embedding usage: %s", e, exc_info=True)
async def get_stats(self): async def get_stats(self):
"""Get current usage statistics""" """Get current usage statistics"""
return self.stats return self.stats
@@ -522,6 +617,9 @@ class UsageStats:
elif model_type == "lora": elif model_type == "lora":
if sha256 in self.stats["loras"]: if sha256 in self.stats["loras"]:
return self.stats["loras"][sha256]["total"] return self.stats["loras"][sha256]["total"]
elif model_type == "embedding":
if sha256 in self.stats["embeddings"]:
return self.stats["embeddings"][sha256]["total"]
return 0 return 0
async def process_execution(self, prompt_id): async def process_execution(self, prompt_id):

View File

@@ -15,30 +15,64 @@ def get_lora_info(lora_name):
scanner = await ServiceRegistry.get_lora_scanner() scanner = await ServiceRegistry.get_lora_scanner()
cache = await scanner.get_cached_data() 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: for item in cache.raw_data:
if item.get("file_name") == lora_name: file_name = item.get("file_name", "")
file_path = item.get("file_path") folder = item.get("folder", "")
if file_path: file_name_no_ext = file_name
# Check all lora roots including extra paths for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
all_roots = list(config.loras_roots or []) + list( if file_name_no_ext.lower().endswith(ext):
config.extra_loras_roots or [] 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", {}) civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else [] trigger_words = (
return file_path, 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, [] return lora_name, []
try: try:
@@ -77,15 +111,54 @@ def get_lora_info_absolute(lora_name):
scanner = await ServiceRegistry.get_lora_scanner() scanner = await ServiceRegistry.get_lora_scanner()
cache = await scanner.get_cached_data() 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: 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") file_path = item.get("file_path")
if file_path: if file_path:
# Return absolute path directly
# Get trigger words from civitai metadata
civitai = item.get("civitai", {}) civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else [] trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words 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, [] return lora_name, []
try: try:

View File

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

View File

@@ -34,6 +34,8 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from platformdirs import user_config_dir
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s", format="%(asctime)s - %(levelname)s - %(message)s",
@@ -53,10 +55,7 @@ def resolve_settings_path() -> Path:
if isinstance(payload, dict) and payload.get("use_portable_settings") is True: if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
return portable return portable
config_home = os.environ.get("XDG_CONFIG_HOME") return Path(user_config_dir(APP_NAME, appauthor=False)) / "settings.json"
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]: def load_json(path: Path) -> dict[str, Any]:

View File

@@ -0,0 +1,403 @@
#!/usr/bin/env python3
"""
Restore original filenames by removing leftover 4-char hash suffixes.
When LoRA Manager's old duplicate filename resolver ran, it appended
``-{first4ofSHA256}`` to duplicate filenames, e.g.::
my_lora.safetensors → my_lora-a3f7.safetensors
With full-path LoRA syntax now available (``<lora:subfolder/name:1.0>``),
these suffixes are unnecessary. This script detects such files and, with
your confirmation, restores their original names.
The same suffix pattern is also used by the download conflict handler
(``{name}-{hash}.{ext}``). To avoid false positives, this script skips
any file whose original name already exists in the same directory — those
were likely added by a download conflict, not the old resolver.
Usage::
# Detect only (dry-run, default)
python scripts/restore_suffixed_filenames.py
# Detect + restore (with confirmation prompt)
python scripts/restore_suffixed_filenames.py --apply
After restoring filenames, run **Rebuild Cache** in the LoRA Manager
Doctor panel to refresh the model cache.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any
from platformdirs import user_config_dir
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
)
logger = logging.getLogger(__name__)
APP_NAME = "ComfyUI-LoRA-Manager"
MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin"}
PREVIEW_EXTENSIONS = {
".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp",
".mp4", ".webm", ".mov",
}
# Matches filenames like "my_lora-a3f7.safetensors"
# Groups: (base_name, 4-char-hex, extension)
_SUFFIX_RE = re.compile(r"^(.+)-([0-9a-f]{4})(\.[^.]+)$")
# ── helpers (copied from migrate_legacy_metadata.py for consistency) ──────────
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
return Path(user_config_dir(APP_NAME, appauthor=False)) / "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, json.JSONDecodeError, OSError):
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]]:
"""Extract model folder roots from LoRA Manager settings.
Returns ``{model_type: [path, ...]}`` where *model_type* is one of
``loras``, ``checkpoints``, ``embeddings``, ``unet``, etc.
Both primary (``folder_paths``) and extra (``extra_folder_paths``)
paths are included. Extra paths can be configured via the UI at
Settings → Model Libraries → Extra Folder Paths.
"""
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:
# Primary folder paths.
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))
# Extra folder paths (Settings → Model Libraries → Extra Folder Paths).
extra_folder_paths = source.get("extra_folder_paths")
if isinstance(extra_folder_paths, dict):
for key, value in extra_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_unet_root", "unet"),
("default_embedding_root", "embeddings"),
):
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]:
"""Recursively find all model files in *directory*."""
files: list[Path] = []
for ext in MODEL_EXTENSIONS:
files.extend(directory.rglob(f"*{ext}"))
return files
# ── core detection logic ──────────────────────────────────────────────────────
def check_file(path: Path) -> tuple[str, str, str] | None:
"""If *path* matches the suffix pattern, return ``(base_name, hex, ext)``.
Returns ``None`` when:
* The filename does not match the pattern, or
* The original name (without the suffix) already exists in the same
directory (likely a download-conflict rename, not a doctor rename).
"""
match = _SUFFIX_RE.match(path.name)
if not match:
return None
base_name = match.group(1)
hex_part = match.group(2)
extension = match.group(3)
orig_name = base_name + extension
orig_path = path.with_name(orig_name)
# Safety: skip if the original name already exists.
if orig_path.exists():
return None
return base_name, hex_part, extension
def scan_roots(
roots: dict[str, list[str]],
) -> dict[str, list[tuple[Path, str, str, str]]]:
"""Scan all model roots and return detected files grouped by model type.
Returns ``{model_type: [(full_path, base_name, hex, ext), ...]}``.
"""
results: dict[str, list[tuple[Path, str, str, str]]] = {}
for model_type, root_list in roots.items():
type_results: list[tuple[Path, str, str, str]] = []
for root in root_list:
root_path = Path(root)
if not root_path.is_dir():
continue
for model_file in find_model_files(root_path):
match = check_file(model_file)
if match:
type_results.append((model_file, *match))
if type_results:
results[model_type] = type_results
return results
def rename_file(
path: Path, base_name: str, extension: str, dry_run: bool
) -> bool:
"""Rename *path* to ``{base_name}{extension}``.
Also renames sidecar files (``.metadata.json``, ``.civitai.info``) and
preview images. Returns ``True`` on success.
"""
new_path = path.with_name(base_name + extension)
old_stem = path.with_suffix("") # /dir/base_name-hex (no ext)
new_stem = new_path.with_suffix("") # /dir/base_name (no ext)
if dry_run:
logger.info(" would rename: %s", path.name)
logger.info(" -> %s", new_path.name)
return True
try:
os.rename(path, new_path)
except OSError as exc:
logger.error(" FAILED to rename %s: %s", path.name, exc)
return False
# Rename sidecar metadata files.
for suffix in (".metadata.json", ".civitai.info"):
old_sidecar = old_stem.with_name(old_stem.name + suffix)
new_sidecar = new_stem.with_name(new_stem.name + suffix)
if old_sidecar.exists():
try:
os.rename(old_sidecar, new_sidecar)
except OSError as exc:
logger.warning(" could not rename sidecar %s: %s", old_sidecar.name, exc)
# Rename preview images.
for preview_ext in PREVIEW_EXTENSIONS:
old_preview = old_stem.with_name(old_stem.name + preview_ext)
new_preview = new_stem.with_name(new_stem.name + preview_ext)
if old_preview.exists():
try:
os.rename(old_preview, new_preview)
except OSError as exc:
logger.warning(" could not rename preview %s: %s", old_preview.name, exc)
logger.info(" renamed: %s -> %s", path.name, new_path.name)
return True
# ── report helpers ────────────────────────────────────────────────────────────
def print_report(results: dict[str, list[tuple[Path, str, str, str]]]) -> int:
"""Print a human-readable report of detected files. Returns total count."""
if not results:
logger.info("No leftover suffixed filenames detected.")
return 0
total = 0
for model_type in sorted(results):
entries = results[model_type]
total += len(entries)
label = model_type.capitalize()
logger.info("")
logger.info("" * 50)
logger.info(" %s (%d file(s))", label, len(entries))
logger.info("" * 50)
for path, base_name, hex_part, ext in sorted(entries):
logger.info(" %s%s%s", path.name, base_name, ext)
logger.info("")
logger.info("=" * 50)
logger.info(" Total: %d file(s) with leftover suffixes.", total)
logger.info("=" * 50)
return total
def prompt_user(count: int) -> bool:
"""Ask the user whether to proceed with the rename."""
try:
answer = input(
f"\nRestore {count} file(s) to their original names? [y/N] "
).strip().lower()
except (EOFError, KeyboardInterrupt):
print()
return False
return answer in ("y", "yes")
# ── main ──────────────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Detect and restore model filenames that have leftover "
"4-character hash suffixes from the old conflict resolver."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" python scripts/restore_suffixed_filenames.py\n"
" python scripts/restore_suffixed_filenames.py --apply\n"
" python scripts/restore_suffixed_filenames.py --apply --yes\n"
),
)
parser.add_argument(
"--apply",
action="store_true",
help="Actually rename files (with confirmation prompt unless --yes is given)",
)
parser.add_argument(
"--yes", "-y",
action="store_true",
help="Skip confirmation prompt (implies --apply)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Detect only — show what would be renamed without making changes",
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Enable debug-level logging",
)
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
# Resolve settings.
settings_path = resolve_settings_path()
logger.info("Settings: %s", settings_path)
settings = _load_json(settings_path)
if not settings:
logger.error("Could not load settings.json. Is LoRA Manager configured?")
return 1
roots = get_model_roots(settings)
if not roots:
logger.error("No model folders found in settings.")
return 1
# Log which roots are being scanned.
for model_type, root_list in roots.items():
for root in root_list:
logger.info("Scanning %s: %s", model_type, root)
# Detect.
results = scan_roots(roots)
total = print_report(results)
if total == 0:
return 0
# Determine mode.
dry_run = not args.apply and not args.yes
if dry_run:
logger.info("\n[Dry-run mode — no files modified]")
logger.info("Run with --apply to restore filenames.")
return 0
# Confirm unless --yes.
if not args.yes:
if not prompt_user(total):
logger.info("Aborted.")
return 0
# Rename.
logger.info("")
success = 0
fail = 0
for model_type in sorted(results):
entries = results[model_type]
logger.info("")
logger.info("" * 50)
logger.info(" Restoring %s (%d file(s))", model_type, len(entries))
logger.info("" * 50)
for path, base_name, hex_part, ext in sorted(entries):
ok = rename_file(path, base_name, ext, dry_run=False)
if ok:
success += 1
else:
fail += 1
logger.info("")
logger.info("=" * 50)
logger.info(" Done: %d restored, %d failed.", success, fail)
logger.info("=" * 50)
logger.info("")
logger.info(" ⚠ Please run Rebuild Cache in the LoRA Manager")
logger.info(" Doctor panel to refresh the model cache.")
return 0 if fail == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -10,13 +10,14 @@
"C:/path/to/your/checkpoints_folder", "C:/path/to/your/checkpoints_folder",
"C:/path/to/another/checkpoints_folder" "C:/path/to/another/checkpoints_folder"
], ],
"unet": [
"C:/path/to/your/diffusion_models_folder",
"C:/path/to/another/diffusion_models_folder"
],
"embeddings": [ "embeddings": [
"C:/path/to/your/embeddings_folder", "C:/path/to/your/embeddings_folder",
"C:/path/to/another/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": [] "auto_organize_exclusions": []
} }

View File

@@ -1,21 +1,20 @@
@import 'tokens/index.css';
html, html,
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
/* Disable default scrolling */
} }
/* 针对Firefox */
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent; scrollbar-color: var(--border-base) transparent;
} }
/* 针对Webkit browsers (Chrome, Safari等) */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: var(--scrollbar-width, 8px);
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@@ -24,116 +23,128 @@ body {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background-color: var(--border-color); background-color: var(--border-base);
border-radius: 4px; border-radius: var(--radius-xs);
} }
:root { :root {
--bg-color: #ffffff;
--text-color: #333333;
--text-muted: #6c757d;
--card-bg: #ffffff;
--border-color: #e0e0e0;
--header-height: 48px; --header-height: 48px;
/* Color Components */
--lora-accent-l: 68%;
--lora-accent-c: 0.28;
--lora-accent-h: 256;
--lora-warning-l: 75%;
--lora-warning-c: 0.25;
--lora-warning-h: 80;
--lora-success-l: 70%;
--lora-success-c: 0.2;
--lora-success-h: 140;
/* Composed Colors */
--lora-accent: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
--lora-surface: oklch(97% 0 0 / 0.95);
--lora-border: oklch(72% 0.03 256 / 0.45);
--lora-text: oklch(95% 0.02 256);
--lora-error: oklch(75% 0.32 29);
--lora-error-bg: color-mix(in oklch, var(--lora-error) 20%, transparent);
--lora-error-border: color-mix(in oklch, var(--lora-error) 50%, transparent);
--lora-warning: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
--lora-success: oklch(var(--lora-success-l) var(--lora-success-c) var(--lora-success-h));
--badge-update-bg: oklch(72% 0.2 220);
--badge-update-text: oklch(28% 0.03 220);
--badge-update-glow: oklch(72% 0.2 220 / 0.28);
--badge-skip-refresh-bg: oklch(82% 0.12 45);
--badge-skip-refresh-text: oklch(35% 0.02 45);
--badge-skip-refresh-glow: oklch(82% 0.12 45 / 0.15);
/* Spacing Scale */
--space-1: calc(8px * 1);
--space-2: calc(8px * 2);
--space-3: calc(8px * 3);
--space-4: calc(8px * 4);
/* Z-index Scale */
--z-base: 10;
--z-header: 100;
--z-modal: 1000;
--z-overlay: 2000;
/* Border Radius */
--border-radius-base: 12px;
--border-radius-md: 12px;
--border-radius-sm: 8px;
--border-radius-xs: 4px;
--scrollbar-width: 8px; --scrollbar-width: 8px;
/* 添加滚动条宽度变量 */
/* Shortcut styles */ --shortcut-bg: var(--color-accent-subtle);
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.12); --shortcut-border: var(--color-accent-border);
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25); --shortcut-text: var(--text-primary);
--shortcut-text: var(--text-color);
--lora-accent-transparent: var(--color-accent-transparent);
/* Legacy spacing aliases: 8px base grid to match existing component usage */
--space-1: 8px;
--space-2: 16px;
--space-3: 24px;
--space-4: 32px;
/* Legacy border-radius aliases to match existing component usage */
--border-radius-xs: 4px;
--border-radius-sm: 6px;
--border-radius-base: 8px;
--border-radius-md: 12px;
--border-radius-lg: 16px;
}
:root {
--bg-color: var(--bg-base);
--text-color: var(--text-primary);
--text-muted: var(--text-secondary);
--card-bg: var(--surface-base);
--border-color: var(--border-base);
--lora-accent: var(--color-accent);
--lora-surface: var(--bg-elevated);
--lora-border: var(--border-subtle);
--lora-text: var(--text-primary);
--lora-error: var(--color-error);
--lora-error-bg: var(--color-error-bg);
--lora-error-border: var(--color-error-border);
--lora-warning: var(--color-warning);
--lora-success: var(--color-success);
--badge-update-bg: var(--color-info-bg);
--badge-update-text: var(--color-info-text);
--badge-update-glow: var(--color-info-glow);
--badge-skip-refresh-bg: var(--color-skip-refresh-bg);
--badge-skip-refresh-text: var(--color-skip-refresh-text);
--badge-skip-refresh-glow: var(--color-skip-refresh-glow);
}
[data-theme="dark"] {
--bg-color: var(--bg-base);
--text-color: var(--text-primary);
--text-muted: var(--text-secondary);
--card-bg: var(--surface-base);
--border-color: var(--border-base);
--lora-accent: var(--color-accent);
--lora-surface: var(--bg-elevated);
--lora-border: var(--border-subtle);
--lora-text: var(--text-primary);
--lora-error: var(--color-error);
--lora-error-bg: var(--color-error-bg);
--lora-error-border: var(--color-error-border);
--lora-warning: var(--color-warning);
--lora-success: var(--color-success);
--badge-update-bg: var(--color-info-bg);
--badge-update-text: var(--color-info-text);
--badge-update-glow: var(--color-info-glow);
--badge-skip-refresh-bg: var(--color-skip-refresh-bg);
--badge-skip-refresh-text: var(--color-skip-refresh-text);
--badge-skip-refresh-glow: var(--color-skip-refresh-glow);
} }
html[data-theme="dark"] { html[data-theme="dark"] {
background-color: #1a1a1a !important; background-color: var(--bg-base) !important;
color-scheme: dark; color-scheme: dark;
} }
html[data-theme="light"] { html[data-theme="light"] {
background-color: #ffffff !important; background-color: var(--bg-base) !important;
color-scheme: light; color-scheme: light;
} }
[data-theme="dark"] {
--bg-color: #1a1a1a;
--text-color: #e0e0e0;
--text-muted: #a0a0a0;
--card-bg: #2d2d2d;
--border-color: #404040;
--lora-accent: oklch(68% 0.28 256);
--lora-surface: oklch(25% 0.02 256 / 0.98);
--lora-border: oklch(90% 0.02 256 / 0.15);
--lora-text: oklch(98% 0.02 256);
--lora-warning: oklch(75% 0.25 80);
/* Modified to be used with oklch() */
--lora-error-bg: color-mix(in oklch, var(--lora-error) 15%, transparent);
--lora-error-border: color-mix(in oklch, var(--lora-error) 40%, transparent);
--badge-update-bg: oklch(62% 0.18 220);
--badge-update-text: oklch(98% 0.02 240);
--badge-update-glow: oklch(62% 0.18 220 / 0.4);
--badge-skip-refresh-bg: oklch(82% 0.12 45);
--badge-skip-refresh-text: oklch(98% 0.02 45);
--badge-skip-refresh-glow: oklch(82% 0.12 45 / 0.15);
}
body { body {
font-family: 'Segoe UI', sans-serif; font-family: var(--font-body);
background: var(--bg-color); background: var(--bg-base);
color: var(--text-color); color: var(--text-primary);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-top: 0; padding-top: 0;
/* Remove the padding-top */
} }
.hidden { .hidden {
display: none !important; display: none !important;
} }
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
button:focus:not(:focus-visible),
input:focus:not(:focus-visible),
select:focus:not(:focus-visible) {
outline: none;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
html {
scroll-behavior: auto !important;
}
}

View File

@@ -46,7 +46,7 @@
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
align-items: center; align-items: center;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-side);
max-height: 80vh; max-height: 80vh;
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin; scrollbar-width: thin;
@@ -75,7 +75,7 @@
width: 20px; width: 20px;
height: 40px; height: 40px;
align-self: center; align-self: center;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-side);
} }
.toggle-alphabet-bar:hover { .toggle-alphabet-bar:hover {
@@ -99,7 +99,7 @@
min-width: 24px; min-width: 24px;
text-align: center; text-align: center;
font-size: 0.85em; font-size: 0.85em;
transition: all 0.2s ease; transition: var(--transition-base);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
@@ -107,7 +107,7 @@
background: var(--lora-accent); background: var(--lora-accent);
color: white; color: white;
transform: scale(1.1); transform: scale(1.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-md);
} }
.letter-chip.active { .letter-chip.active {

View File

@@ -68,7 +68,7 @@
text-decoration: none; text-decoration: none;
font-size: 0.85em; font-size: 0.85em;
font-weight: 500; font-weight: 500;
transition: all 0.2s ease; transition: var(--transition-base);
white-space: nowrap; white-space: nowrap;
border: 1px solid transparent; border: 1px solid transparent;
} }
@@ -102,7 +102,7 @@
color: white; color: white;
border-color: var(--lora-accent); border-color: var(--lora-accent);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-md);
} }
/* Tertiary Action Button */ /* Tertiary Action Button */
@@ -133,7 +133,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s ease; transition: var(--transition-base);
font-size: 0.8em; font-size: 0.8em;
} }

View File

@@ -76,7 +76,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s; transition: var(--transition-base);
background: var(--bg-color); background: var(--bg-color);
} }
@@ -166,7 +166,7 @@
background: var(--card-bg); background: var(--card-bg);
color: var(--text-color); color: var(--text-color);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: var(--transition-base);
} }
.back-btn:hover { .back-btn:hover {
@@ -237,7 +237,7 @@
padding: 8px 10px; padding: 8px 10px;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: var(--transition-base);
border: 1px solid transparent; border: 1px solid transparent;
} }

View File

@@ -1,12 +1,12 @@
/* 卡片网格布局 */ /* Card grid layout */
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */
gap: 12px; /* Consistent gap for both row and column spacing */ gap: 12px; /* Consistent gap for both row and column spacing */
row-gap: 20px; /* Increase vertical spacing between rows */ row-gap: 20px; /* Increase vertical spacing between rows */
margin-top: var(--space-2); margin-top: var(--space-2);
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */ padding-top: 4px;
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */ padding-bottom: 4px;
width: 100%; /* Ensure it takes full width of container */ width: 100%; /* Ensure it takes full width of container */
max-width: 1400px; /* Base container width */ max-width: 1400px; /* Base container width */
margin-left: auto; margin-left: auto;
@@ -19,7 +19,7 @@
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
transition: transform 160ms ease-out; transition: transform var(--transition-fast) ease-out, box-shadow var(--transition-fast) ease-out, border-color var(--transition-fast) ease-out;
aspect-ratio: 896/1152; /* Preserve aspect ratio */ aspect-ratio: 896/1152; /* Preserve aspect ratio */
max-width: 260px; /* Base size */ max-width: 260px; /* Base size */
min-width: 200px; /* Prevent cards from becoming too narrow */ min-width: 200px; /* Prevent cards from becoming too narrow */
@@ -33,7 +33,8 @@
.model-card:hover { .model-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
background: oklch(100% 0 0 / 0.6); box-shadow: var(--shadow-md);
border-color: var(--lora-accent);
} }
.model-card:focus-visible { .model-card:focus-visible {
@@ -353,21 +354,26 @@
} }
.card-actions { .card-actions {
flex-shrink: 0;
display: flex; display: flex;
gap: var(--space-1); /* Use gap instead of margin for spacing between icons */ gap: var(--space-1);
align-items: center; align-items: flex-end;
align-self: flex-end;
} }
.card-actions i:hover { .card-actions i:hover,
.card-actions i:focus-visible {
opacity: 0.9; opacity: 0.9;
transform: scale(1.1); transform: scale(1.1);
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
outline: 2px solid var(--lora-accent);
outline-offset: 2px;
border-radius: var(--border-radius-xs);
} }
/* Style for active favorites */
.favorite-active { .favorite-active {
color: #ffc107 !important; /* Gold color for favorites */ color: var(--favorite-color) !important;
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5); text-shadow: 0 0 5px var(--favorite-glow);
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
@@ -391,14 +397,6 @@
} }
} }
.card-actions {
flex-shrink: 0; /* Prevent actions from shrinking */
display: flex;
gap: var(--space-1);
align-items: flex-end; /* 将图标靠下对齐 */
align-self: flex-end; /* 将整个actions容器靠下对齐 */
}
.model-link { .model-link {
margin-top: var(--space-1); margin-top: var(--space-1);
} }
@@ -411,9 +409,13 @@
text-shadow: none; text-shadow: none;
} }
.model-link a:hover { .model-link a:hover,
.model-link a:focus-visible {
opacity: 0.8; opacity: 0.8;
text-decoration: none; text-decoration: none;
outline: 2px solid var(--lora-accent);
outline-offset: 2px;
border-radius: var(--border-radius-xs);
} }
/* Updated model name to fix text cutoff issues */ /* Updated model name to fix text cutoff issues */
@@ -438,7 +440,7 @@
.base-model { .base-model {
display: inline-block; display: inline-block;
background: #f0f0f0; background: var(--surface-hover, oklch(95% 0 0));
padding: 2px 6px; padding: 2px 6px;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
margin-right: 6px; margin-right: 6px;
@@ -507,21 +509,96 @@
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */ 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 adjustments for version name */
.medium-density .version-name { .medium-density .version-name {
font-size: 0.8em; 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 adjustments for version name */
.compact-density .version-name { .compact-density .version-name {
font-size: 0.75em; font-size: 0.75em;
} }
/* Hide civitai version name when setting is disabled */ .compact-density .badge-version-unit .version-name {
body.hide-card-version .civitai-version { 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; display: none;
} }
/* Compact density adjustments for version name */
.compact-density .version-name {
font-size: 0.75em;
}
/* Prevent text selection on cards and interactive elements */ /* Prevent text selection on cards and interactive elements */
.model-card, .model-card,
.model-card *, .model-card *,

View File

@@ -11,8 +11,8 @@
border-bottom: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */ border-bottom: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */
z-index: var(--z-overlay); z-index: var(--z-overlay);
padding: 12px 0; padding: 12px 0;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); /* Stronger shadow */ box-shadow: var(--shadow-lg); /* Stronger shadow */
transition: all 0.3s ease; transition: var(--transition-slow);
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -65,7 +65,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
transition: all 0.2s ease; transition: var(--transition-base);
} }
.duplicates-banner button.btn-exit-mode:hover { .duplicates-banner button.btn-exit-mode:hover {
@@ -86,16 +86,16 @@
background: var(--card-bg); background: var(--card-bg);
color: var(--text-color); color: var(--text-color);
font-size: 0.85em; font-size: 0.85em;
transition: all 0.2s ease; transition: var(--transition-base);
cursor: pointer; cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); box-shadow: var(--shadow-xs);
} }
.duplicates-banner button:hover { .duplicates-banner button:hover {
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h); border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
background: var(--bg-color); background: var(--bg-color);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08); box-shadow: var(--shadow-sm);
} }
.duplicates-banner button.btn-exit { .duplicates-banner button.btn-exit {
@@ -122,7 +122,7 @@
padding: 16px; padding: 16px;
margin-bottom: 24px; margin-bottom: 24px;
background: var(--card-bg); background: var(--card-bg);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12); /* Add subtle shadow to groups */ box-shadow: var(--shadow-md); /* Add subtle shadow to groups */
/* Add responsive width settings to match banner */ /* Add responsive width settings to match banner */
max-width: 1400px; max-width: 1400px;
margin-left: auto; margin-left: auto;
@@ -173,9 +173,9 @@
background: var(--card-bg); background: var(--card-bg);
color: var(--text-color); color: var(--text-color);
font-size: 0.85em; font-size: 0.85em;
transition: all 0.2s ease; transition: var(--transition-base);
cursor: pointer; cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); box-shadow: var(--shadow-xs);
margin-left: 8px; margin-left: 8px;
} }
@@ -183,7 +183,7 @@
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h); border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
background: var(--bg-color); background: var(--bg-color);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08); box-shadow: var(--shadow-sm);
} }
.card-group-container { .card-group-container {
@@ -230,20 +230,20 @@
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
z-index: 1; z-index: 1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
transition: all 0.2s ease; transition: var(--transition-base);
} }
.group-toggle-btn:hover { .group-toggle-btn:hover {
border-color: var(--lora-accent-l) var(--lora-accent-c) var (--lora-accent-h); border-color: var(--lora-accent-l) var(--lora-accent-c) var (--lora-accent-h);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08); box-shadow: var(--shadow-sm);
} }
/* Duplicate card styling */ /* Duplicate card styling */
.model-card.duplicate { .model-card.duplicate {
position: relative; position: relative;
transition: all 0.2s ease; transition: var(--transition-base);
} }
.model-card.duplicate:hover { .model-card.duplicate:hover {
@@ -257,7 +257,7 @@
.model-card.duplicate-selected { .model-card.duplicate-selected {
border: 2px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h)); border: 2px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
box-shadow: 0 0 8px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-md);
} }
.model-card .selector-checkbox { .model-card .selector-checkbox {
@@ -290,7 +290,7 @@
background-color: var(--card-bg); background-color: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
box-shadow: 0 2px 10px rgba(0,0,0,0.2); box-shadow: var(--shadow-lg);
padding: 10px; padding: 10px;
z-index: 1000; z-index: 1000;
max-width: 350px; max-width: 350px;
@@ -432,7 +432,7 @@
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
font-size: 0.85em; font-size: 0.85em;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: var(--transition-base);
} }
.btn-verify-hashes:hover { .btn-verify-hashes:hover {
@@ -461,7 +461,7 @@
position: absolute; position: absolute;
top: -8px; /* Moved closer to button */ top: -8px; /* Moved closer to button */
right: -8px; /* Moved closer to button */ right: -8px; /* Moved closer to button */
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); /* Softer shadow */ box-shadow: var(--shadow-sm); /* Softer shadow */
transition: transform 0.2s ease, opacity 0.2s ease; transition: transform 0.2s ease, opacity 0.2s ease;
} }
@@ -493,7 +493,7 @@
cursor: help; cursor: help;
font-size: 16px; font-size: 16px;
margin-left: 8px; margin-left: 8px;
transition: all 0.2s ease; transition: var(--transition-base);
} }
.help-icon:hover { .help-icon:hover {
@@ -511,7 +511,7 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
padding: 12px 16px; padding: 12px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-elevated);
z-index: var(--z-overlay); z-index: var(--z-overlay);
font-size: 0.9em; font-size: 0.9em;
margin-top: 10px; margin-top: 10px;
@@ -572,16 +572,16 @@
/* In dark mode, add additional distinction */ /* In dark mode, add additional distinction */
html[data-theme="dark"] .duplicates-banner { html[data-theme="dark"] .duplicates-banner {
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.4); /* Stronger shadow in dark mode */ box-shadow: var(--shadow-dark-lg); /* Stronger shadow in dark mode */
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); /* Slightly stronger background in dark mode */ background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); /* Slightly stronger background in dark mode */
} }
html[data-theme="dark"] .duplicate-group { html[data-theme="dark"] .duplicate-group {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); /* Stronger shadow in dark mode */ box-shadow: var(--shadow-lg); /* Stronger shadow in dark mode */
} }
html[data-theme="dark"] .help-tooltip { html[data-theme="dark"] .help-tooltip {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: var(--shadow-elevated);
} }
/* Styles for disabled controls during duplicates mode */ /* Styles for disabled controls during duplicates mode */

View File

@@ -7,22 +7,22 @@
color: white; color: white;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: 4px 10px; padding: 4px 10px;
transition: all 0.2s ease; transition: var(--transition-base);
border: 1px solid var(--lora-accent); border: 1px solid var(--lora-accent);
cursor: pointer; cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
font-size: 0.85em; font-size: 0.85em;
} }
.control-group .filter-active:hover { .control-group .filter-active:hover {
opacity: 0.92; opacity: 0.92;
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-md);
} }
.control-group .filter-active:active { .control-group .filter-active:active {
transform: translateY(0); transform: translateY(0);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
} }
.control-group .filter-active i.fa-filter { .control-group .filter-active i.fa-filter {
@@ -59,9 +59,9 @@
/* Animation for filter indicator */ /* Animation for filter indicator */
@keyframes filterPulse { @keyframes filterPulse {
0% { transform: scale(1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } 0% { transform: scale(1); box-shadow: var(--shadow-sm); }
50% { transform: scale(1.03); box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15); } 50% { transform: scale(1.03); box-shadow: var(--shadow-lg); }
100% { transform: scale(1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } 100% { transform: scale(1); box-shadow: var(--shadow-sm); }
} }
.filter-active.animate { .filter-active.animate {

View File

@@ -7,7 +7,7 @@
height: 48px; height: 48px;
/* Reduced height */ /* Reduced height */
width: 100%; width: 100%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-md);
/* Slightly stronger shadow */ /* Slightly stronger shadow */
} }
@@ -134,14 +134,14 @@
background: var(--input-bg, var(--card-bg)); background: var(--input-bg, var(--card-bg));
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm, 6px); border-radius: var(--border-radius-sm, 6px);
transition: all 0.2s ease; transition: border-color var(--transition-base), box-shadow var(--transition-base);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); box-shadow: var(--shadow-header);
overflow: hidden; overflow: hidden;
} }
.header-search .search-container:focus-within { .header-search .search-container:focus-within {
border-color: var(--lora-accent); border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px var(--lora-accent); box-shadow: var(--shadow-header), 0 0 0 1px var(--lora-accent);
} }
.header-search input { .header-search input {
@@ -183,7 +183,7 @@
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
border-radius: var(--border-radius-xs, 4px); border-radius: var(--border-radius-xs, 4px);
transition: all 0.2s ease; transition: background-color var(--transition-base), color var(--transition-base);
} }
.header-search .search-options-toggle { .header-search .search-options-toggle {
@@ -191,9 +191,11 @@
} }
.header-search .search-options-toggle:hover, .header-search .search-options-toggle:hover,
.header-search .search-filter-toggle:hover { .header-search .search-filter-toggle:hover,
background: var(--lora-surface-hover, oklch(95% 0.02 256)); .header-search .search-filter-toggle:focus-visible {
color: var(--lora-accent); background: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
outline: none;
} }
.header-search .filter-badge { .header-search .filter-badge {
@@ -269,7 +271,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: background-color var(--transition-base), color var(--transition-base), transform var(--transition-base);
position: relative; position: relative;
} }
@@ -341,7 +343,7 @@
background-color: var(--lora-error); background-color: var(--lora-error);
border-radius: 50%; border-radius: 50%;
border: 2px solid var(--card-bg); border: 2px solid var(--card-bg);
transition: all 0.2s ease; transition: opacity var(--transition-base);
pointer-events: none; pointer-events: none;
opacity: 0; opacity: 0;
} }
@@ -362,13 +364,22 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: background-color var(--transition-base), color var(--transition-base);
flex-shrink: 0; flex-shrink: 0;
} }
.hamburger-menu-btn:hover { .hamburger-menu-btn:hover,
background: var(--lora-accent); .hamburger-menu-btn:focus-visible {
color: white; background: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
outline: none;
}
.hamburger-dropdown .dropdown-item:hover,
.hamburger-dropdown .dropdown-item:focus-visible {
background: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
outline: none;
} }
/* Hamburger dropdown menu */ /* Hamburger dropdown menu */
@@ -381,7 +392,7 @@
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm, 6px); border-radius: var(--border-radius-sm, 6px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-toast);
padding: 0.5rem; padding: 0.5rem;
min-width: 160px; min-width: 160px;
z-index: var(--z-dropdown, 200); z-index: var(--z-dropdown, 200);
@@ -401,7 +412,7 @@
border-radius: var(--border-radius-xs, 4px); border-radius: var(--border-radius-xs, 4px);
color: var(--text-color); color: var(--text-color);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: background-color var(--transition-base), color var(--transition-base);
font-size: 0.9rem; font-size: 0.9rem;
white-space: nowrap; white-space: nowrap;
} }

View File

@@ -757,7 +757,7 @@
position: relative; position: relative;
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
overflow: hidden; overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-md);
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }

View File

@@ -176,7 +176,7 @@
background: rgba(var(--lora-accent), 0.05); background: rgba(var(--lora-accent), 0.05);
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
padding: var(--space-2); padding: var(--space-2);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); box-shadow: var(--shadow-sm);
} }
.tips-header { .tips-header {

View File

@@ -11,7 +11,7 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-color); color: var(--text-color);
cursor: help; cursor: help;
transition: all 0.2s ease; transition: var(--transition-base);
margin-left: 8px; margin-left: 8px;
} }
@@ -19,7 +19,7 @@
background: var(--lora-accent); background: var(--lora-accent);
color: white; color: white;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08); box-shadow: var(--shadow-sm);
} }
.keyboard-nav-hint i { .keyboard-nav-hint i {
@@ -46,7 +46,7 @@
transform: translateY(-15%); /* Vertically center */ transform: translateY(-15%); /* Vertically center */
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-lg);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
font-size: 0.85em; font-size: 0.85em;
line-height: 1.4; line-height: 1.4;
@@ -92,5 +92,5 @@
border-radius: 3px; border-radius: 3px;
padding: 1px 5px; padding: 1px 5px;
font-size: 0.8em; font-size: 0.8em;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); box-shadow: var(--shadow-xs);
} }

View File

@@ -18,7 +18,7 @@
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
text-align: center; text-align: center;
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
width: min(400px, 90vw); /* 固定最大宽度,但保持响应式 */ width: min(400px, 90vw);
} }
.loading-spinner { .loading-spinner {
@@ -33,7 +33,7 @@
.loading-status { .loading-status {
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--text-color); /* 使用主题文本颜色 */ color: var(--text-color);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -42,11 +42,11 @@
} }
.progress-container { .progress-container {
width: 280px; /* 固定进度条宽度 */ width: 280px;
background-color: var(--lora-border); /* 使用主题边框颜色 */ background-color: var(--lora-border);
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
margin: 0 auto; /* 居中显示 */ margin: 0 auto;
} }
.progress-bar { .progress-bar {

View File

@@ -62,7 +62,7 @@
} }
.model-description-content code { .model-description-content code {
font-family: monospace; font-family: var(--font-mono);
font-size: 0.9em; font-size: 0.9em;
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
padding: 0.1em 0.3em; padding: 0.1em 0.3em;

View File

@@ -105,14 +105,14 @@
.info-item { .info-item {
padding: var(--space-2); padding: var(--space-2);
background: rgba(0, 0, 0, 0.03); background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
} }
/* 调整深色主题下的样式 */ /* Dark theme info item styles */
[data-theme="dark"] .info-item { [data-theme="dark"] .info-item {
background: rgba(255, 255, 255, 0.03); background: var(--surface-subtle);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
} }
@@ -140,18 +140,70 @@
/* Add specific styles for notes content */ /* Add specific styles for notes content */
.info-item.notes .editable-field [contenteditable] { .info-item.notes .editable-field [contenteditable] {
height: 60px; /* Keep initial modal layout stable regardless of note length */ min-height: 60px;
min-height: 60px; /* Increase height for multiple lines */ white-space: pre-wrap;
max-height: 420px; /* Limit maximum height */ line-height: 1.5;
overflow: auto; /* Enable scrolling and resize handle for long content */ padding: 8px 12px;
resize: vertical; /* Allow manual vertical resizing */ }
white-space: pre-wrap; /* Preserve line breaks */
line-height: 1.5; /* Improve readability */ /* Notes expand/collapse — collapsed by default; only applies when JS detects long content */
padding: 8px 12px; /* Slightly increase padding */ .info-item.notes .editable-field {
position: relative;
max-height: none;
overflow: visible;
}
.info-item.notes .editable-field.collapsed {
max-height: 80px;
overflow: hidden;
}
/* Gradient fade overlay hint when collapsed */
.info-item.notes .editable-field.collapsed::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 28px;
background: linear-gradient(transparent, var(--bg-color));
pointer-events: none;
}
/* Notes header row — label left, toggle button right */
.notes-header {
display: flex;
align-items: center;
justify-content: space-between;
}
/* Toggle button — icon only, inline with the label */
.notes-toggle-btn {
display: none; /* shown by JS when content exceeds threshold */
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: none;
background: none;
color: var(--lora-accent);
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
flex-shrink: 0;
}
.notes-toggle-btn:hover {
background: rgba(66, 153, 225, 0.1);
}
.notes-toggle-btn i {
font-size: 0.85em;
} }
.file-path { .file-path {
font-family: monospace; font-family: var(--font-mono);
font-size: 0.9em; font-size: 0.9em;
} }
@@ -219,13 +271,13 @@
} }
} }
/* 修改 back-to-top 按钮样式,使其固定在 modal 内部 */ /* Back-to-top button pinned inside modal */
.modal-content .back-to-top { .modal-content .back-to-top {
position: sticky; /* 改用 sticky 定位 */ position: sticky;
float: right; /* 使用 float 确保按钮在右侧 */ float: right;
bottom: 20px; /* 距离底部的距离 */ bottom: 20px;
margin-right: 20px; /* 右侧间距 */ margin-right: 20px;
margin-top: -56px; /* 负边距确保不占用额外空间 */ margin-top: -56px;
width: 36px; width: 36px;
height: 36px; height: 36px;
border-radius: 50%; border-radius: 50%;
@@ -239,7 +291,7 @@
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transform: translateY(10px); transform: translateY(10px);
transition: all 0.3s ease; transition: opacity var(--transition-slow), visibility var(--transition-slow), transform var(--transition-slow);
z-index: 10; z-index: 10;
} }
@@ -255,35 +307,39 @@
transform: translateY(-2px); transform: translateY(-2px);
} }
/* File name copy styles */ /* Editable inline field styles (file name, version name, etc.) */
.file-name-wrapper { .file-name-wrapper,
.version-name-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 4px; padding: 4px 0;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
transition: background-color 0.2s; transition: background-color 0.2s;
position: relative; position: relative;
} }
.file-name-content { .file-name-content,
padding: 2px 4px; .version-name-content {
padding: 2px 4px 2px 0;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
border: 1px solid transparent; border: 1px solid transparent;
flex: 1; 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); border: 1px solid var(--lora-accent);
background: var(--bg-color); background: var(--bg-color);
outline: none; outline: none;
} }
/* 合并编辑按钮样式 */ /* Consolidated edit button styles */
.edit-model-name-btn, .edit-model-name-btn,
.edit-file-name-btn, .edit-file-name-btn,
.edit-base-model-btn, .edit-base-model-btn,
.edit-model-description-btn { .edit-model-description-btn,
.edit-version-name-btn {
background: transparent; background: transparent;
border: none; border: none;
color: var(--text-color); color: var(--text-color);
@@ -291,7 +347,7 @@
cursor: pointer; cursor: pointer;
padding: 2px 5px; padding: 2px 5px;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
transition: all 0.2s ease; transition: opacity var(--transition-base), background-color var(--transition-base);
margin-left: var(--space-1); margin-left: var(--space-1);
} }
@@ -299,9 +355,11 @@
.edit-file-name-btn.visible, .edit-file-name-btn.visible,
.edit-base-model-btn.visible, .edit-base-model-btn.visible,
.edit-model-description-btn.visible, .edit-model-description-btn.visible,
.edit-version-name-btn.visible,
.model-name-header:hover .edit-model-name-btn, .model-name-header:hover .edit-model-name-btn,
.file-name-wrapper:hover .edit-file-name-btn, .file-name-wrapper:hover .edit-file-name-btn,
.base-model-display:hover .edit-base-model-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 { .model-name-header:hover .edit-model-description-btn {
opacity: 0.5; opacity: 0.5;
} }
@@ -309,14 +367,16 @@
.edit-model-name-btn:hover, .edit-model-name-btn:hover,
.edit-file-name-btn:hover, .edit-file-name-btn:hover,
.edit-base-model-btn:hover, .edit-base-model-btn:hover,
.edit-model-description-btn:hover { .edit-model-description-btn:hover,
opacity: 0.8 !important; .edit-version-name-btn:hover {
opacity: 0.8;
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
} }
[data-theme="dark"] .edit-model-name-btn:hover, [data-theme="dark"] .edit-model-name-btn:hover,
[data-theme="dark"] .edit-file-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); background: rgba(255, 255, 255, 0.05);
} }
@@ -327,7 +387,7 @@
} }
.base-wrapper { .base-wrapper {
flex: 2; /* 分配更多空间给base model */ flex: 2; /* Allocate more space to base model */
} }
/* Base model display and editing styles */ /* Base model display and editing styles */
@@ -338,7 +398,7 @@
} }
.base-model-content { .base-model-content {
padding: 2px 4px; padding: 2px 4px 2px 0;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
border: 1px solid transparent; border: 1px solid transparent;
color: var(--text-color); color: var(--text-color);
@@ -370,7 +430,7 @@
} }
.size-wrapper span { .size-wrapper span {
font-family: monospace; font-family: var(--font-mono);
font-size: 0.9em; font-size: 0.9em;
opacity: 0.9; opacity: 0.9;
} }
@@ -387,7 +447,7 @@
margin: 0; margin: 0;
padding: var(--space-1); padding: var(--space-1);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
font-size: 1.5em !important; font-size: 1.5em;
font-weight: 600; font-weight: 600;
line-height: 1.2; line-height: 1.2;
color: var(--text-color); color: var(--text-color);
@@ -423,7 +483,7 @@
color: var(--text-color); color: var(--text-color);
cursor: pointer; cursor: pointer;
font-size: 0.95em; font-size: 0.95em;
transition: all 0.2s; transition: var(--transition-base);
opacity: 0.7; opacity: 0.7;
position: relative; position: relative;
} }
@@ -828,18 +888,18 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 2px 10px; padding: 2px 10px;
background: rgba(0, 0, 0, 0.03); background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
max-width: fit-content; max-width: fit-content;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: var(--transition-base);
} }
[data-theme="dark"] .creator-info, [data-theme="dark"] .creator-info,
[data-theme="dark"] .civitai-view, [data-theme="dark"] .civitai-view,
[data-theme="dark"] .modal-send-btn { [data-theme="dark"] .modal-send-btn {
background: rgba(255, 255, 255, 0.03); background: var(--surface-subtle);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
} }
@@ -898,14 +958,14 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 12px; padding: 6px 12px;
background: rgba(0, 0, 0, 0.03); background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
color: var(--text-color); color: var(--text-color);
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
font-size: 0.9em; font-size: 0.9em;
transition: all 0.2s; transition: var(--transition-base);
} }
.civitai-view i { .civitai-view i {
@@ -921,18 +981,18 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 12px; padding: 6px 12px;
background: rgba(0, 0, 0, 0.03); background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
color: var(--text-color); color: var(--text-color);
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
font-size: 0.9em; font-size: 0.9em;
transition: all 0.2s; transition: var(--transition-base);
} }
[data-theme="dark"] .modal-send-btn { [data-theme="dark"] .modal-send-btn {
background: rgba(255, 255, 255, 0.03); background: var(--surface-subtle);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
} }

View File

@@ -28,7 +28,7 @@
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: calc(var(--space-1) * 0.5) var(--space-1); padding: calc(var(--space-1) * 0.5) var(--space-1);
gap: var(--space-1); gap: var(--space-1);
transition: all 0.2s ease; transition: var(--transition-base);
} }
.preset-tag span { .preset-tag span {
@@ -40,7 +40,7 @@
color: var(--text-color); color: var(--text-color);
opacity: 0.5; opacity: 0.5;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: var(--transition-base);
} }
.preset-tag:hover { .preset-tag:hover {

View File

@@ -111,8 +111,8 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: var(--transition-base);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-md);
padding: 0; padding: 0;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -120,7 +120,7 @@
.media-control-btn:hover { .media-control-btn:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-lg);
} }
.media-control-btn.set-preview-btn:hover { .media-control-btn.set-preview-btn:hover {
@@ -141,8 +141,9 @@
border-color: var(--lora-error); border-color: var(--lora-error);
} }
/* Disabled state for delete button */ /* Disabled state for delete and create-recipe buttons */
.media-control-btn.example-delete-btn.disabled { .media-control-btn.example-delete-btn.disabled,
.media-control-btn.create-recipe-btn.disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
@@ -204,7 +205,7 @@
z-index: 5; z-index: 5;
max-height: 50%; /* Reduced to take less space */ max-height: 50%; /* Reduced to take less space */
overflow-y: auto; overflow-y: auto;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-inset-top);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
@@ -219,7 +220,7 @@
/* Adjust to dark theme */ /* Adjust to dark theme */
[data-theme="dark"] .image-metadata-panel { [data-theme="dark"] .image-metadata-panel {
background: var(--card-bg); background: var(--card-bg);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3); box-shadow: var(--shadow-inset-top);
} }
.metadata-content { .metadata-content {
@@ -296,7 +297,7 @@
.metadata-prompt { .metadata-prompt {
color: var(--text-color); color: var(--text-color);
font-family: monospace; font-family: var(--font-mono);
font-size: 0.85em; font-size: 0.85em;
white-space: pre-wrap; white-space: pre-wrap;
} }
@@ -311,7 +312,7 @@
opacity: 0.6; opacity: 0.6;
cursor: pointer; cursor: pointer;
padding: 3px; padding: 3px;
transition: all 0.2s ease; transition: var(--transition-base);
} }
.copy-prompt-btn:hover { .copy-prompt-btn:hover {
@@ -408,7 +409,7 @@
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
padding: var(--space-4); padding: var(--space-4);
text-align: center; text-align: center;
transition: all 0.3s ease; transition: var(--transition-slow);
background: var(--lora-surface); background: var(--lora-surface);
cursor: pointer; cursor: pointer;
} }
@@ -454,9 +455,9 @@
} }
.import-formats { .import-formats {
font-size: 0.8em !important; font-size: 0.8em;
opacity: 0.6 !important; opacity: 0.6;
margin-top: var(--space-2) !important; margin-top: var(--space-2);
} }
.select-files-btn { .select-files-btn {
@@ -470,7 +471,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
transition: all 0.2s; transition: var(--transition-base);
} }
.select-files-btn:hover { .select-files-btn:hover {
@@ -480,7 +481,7 @@
/* For dark theme */ /* For dark theme */
[data-theme="dark"] .import-container { [data-theme="dark"] .import-container {
background: rgba(255, 255, 255, 0.03); background: var(--surface-subtle);
} }
/* Setup Guidance State - When example images path is not configured */ /* Setup Guidance State - When example images path is not configured */

View File

@@ -21,7 +21,7 @@
.model-tag-compact { .model-tag-compact {
/* Updated styles to match info-item appearance */ /* Updated styles to match info-item appearance */
background: rgba(0, 0, 0, 0.03); background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: 2px 8px; padding: 2px 8px;
@@ -45,7 +45,7 @@
/* Adjust dark theme tag styles */ /* Adjust dark theme tag styles */
[data-theme="dark"] .model-tag-compact { [data-theme="dark"] .model-tag-compact {
background: rgba(255, 255, 255, 0.03); background: var(--surface-subtle);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
} }
@@ -73,14 +73,14 @@
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-lg);
padding: 10px 14px; padding: 10px 14px;
max-width: 400px; max-width: 400px;
z-index: 10; z-index: 10;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transform: translateY(-4px); transform: translateY(-4px);
transition: all 0.2s ease; transition: var(--transition-base);
pointer-events: none; pointer-events: none;
} }
@@ -101,7 +101,7 @@
.tooltip-tag { .tooltip-tag {
/* Updated styles to match info-item appearance */ /* Updated styles to match info-item appearance */
background: rgba(0, 0, 0, 0.03); background: var(--surface-hover);
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: 3px 8px; padding: 3px 8px;
@@ -111,7 +111,7 @@
/* Adjust dark theme tooltip tag styles */ /* Adjust dark theme tooltip tag styles */
[data-theme="dark"] .tooltip-tag { [data-theme="dark"] .tooltip-tag {
background: rgba(255, 255, 255, 0.03); background: var(--surface-hover);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
} }
@@ -130,7 +130,7 @@
cursor: pointer; cursor: pointer;
padding: 2px 5px; padding: 2px 5px;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
transition: all 0.2s ease; transition: var(--transition-base);
margin-left: var(--space-1); margin-left: var(--space-1);
} }

View File

@@ -1,14 +1,14 @@
/* Update Trigger Words styles */ /* Update Trigger Words styles */
.info-item.trigger-words { .info-item.trigger-words {
padding: var(--space-2); padding: var(--space-2);
background: rgba(0, 0, 0, 0.03); background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
} }
/* 调整 trigger words 样式 */ /* Trigger words styles */
[data-theme="dark"] .info-item.trigger-words { [data-theme="dark"] .info-item.trigger-words {
background: rgba(255, 255, 255, 0.03); background: var(--surface-subtle);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
} }
@@ -48,7 +48,7 @@
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: 4px 8px; padding: 4px 8px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: var(--transition-base);
gap: 6px; gap: 6px;
position: relative; position: relative;
} }

View File

@@ -146,7 +146,7 @@
background: color-mix(in oklch, var(--card-bg) 92%, var(--bg-color) 8%); background: color-mix(in oklch, var(--card-bg) 92%, var(--bg-color) 8%);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); box-shadow: var(--shadow-xs);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
} }
@@ -156,7 +156,7 @@
.model-version-row:hover { .model-version-row:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); box-shadow: var(--shadow-xl);
} }
.model-version-row.is-clickable { .model-version-row.is-clickable {
@@ -186,7 +186,7 @@
height: 88px; height: 88px;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
overflow: hidden; overflow: hidden;
background: rgba(0, 0, 0, 0.03); background: var(--surface-hover);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

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: var(--shadow-dark-lg);
}
.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

@@ -5,7 +5,7 @@
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: 4px 0; padding: 4px 0;
min-width: 180px; min-width: 180px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-dropdown);
z-index: 1000; z-index: 1000;
display: none; display: none;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
@@ -21,9 +21,11 @@
background: var(--lora-surface); background: var(--lora-surface);
} }
.context-menu-item:hover { .context-menu-item:hover,
.context-menu-item:focus-visible {
background-color: var(--lora-accent); background-color: var(--lora-accent);
color: var(--lora-text); color: var(--lora-text);
outline: none;
} }
.context-menu-separator { .context-menu-separator {
@@ -32,6 +34,12 @@
margin: 4px 0; margin: 4px 0;
} }
/* Lighter separator between category groups (vs the full separator before destructive) */
.context-menu-separator.menu-section-break {
opacity: 0.4;
margin: 3px 0;
}
.context-menu-item.delete-item { .context-menu-item.delete-item {
color: var(--danger-color); color: var(--danger-color);
} }
@@ -41,6 +49,63 @@
text-align: center; 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: var(--shadow-dropdown);
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 */
.nsfw-level-selector { .nsfw-level-selector {
position: fixed; position: fixed;
@@ -51,7 +116,7 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
padding: 16px; padding: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-modal);
z-index: var(--z-modal); z-index: var(--z-modal);
width: 300px; width: 300px;
display: none; display: none;
@@ -105,7 +170,7 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-color); color: var(--text-color);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: var(--transition-base);
} }
.nsfw-level-btn:hover { .nsfw-level-btn:hover {
@@ -129,7 +194,7 @@
max-width: 350px; max-width: 350px;
max-height: 400px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-dropdown);
z-index: var(--z-overlay); z-index: var(--z-overlay);
display: none; display: none;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);

View File

@@ -1,4 +1,4 @@
/* modal 基础样式 */ /* Modal base styles */
.modal { .modal {
display: none; display: none;
position: fixed; position: fixed;
@@ -6,19 +6,19 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: calc(100% - var(--header-height, 48px)); /* Adjust height to exclude header */ height: calc(100% - var(--header-height, 48px)); /* Adjust height to exclude header */
background: rgba(0, 0, 0, 0.2); /* 调整为更淡的半透明黑色 */ background: rgba(0, 0, 0, 0.2);
z-index: var(--z-modal); z-index: var(--z-modal);
overflow: auto; /* Change from hidden to auto to allow scrolling */ overflow: auto; /* Change from hidden to auto to allow scrolling */
} }
/* 当模态窗口打开时禁止body滚动 */ /* Prevent body scroll when modal is open */
body.modal-open { body.modal-open {
position: fixed; position: fixed;
width: 100%; width: 100%;
padding-right: var(--scrollbar-width, 0px); /* 补偿滚动条消失导致的页面偏移 */ padding-right: var(--scrollbar-width, 0px);
} }
/* modal-content 样式 */ /* Modal content styles */
.modal-content { .modal-content {
position: relative; position: relative;
max-width: 800px; max-width: 800px;
@@ -29,12 +29,9 @@ body.modal-open {
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
padding: var(--space-3); padding: var(--space-3);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
box-shadow: box-shadow: var(--shadow-md);
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06),
0 10px 15px -3px rgba(0, 0, 0, 0.05);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; /* 防止水平滚动条 */ overflow-x: hidden;
scrollbar-gutter: stable both-edges; /* Reserve space to prevent layout shift when scrollbar toggles */ scrollbar-gutter: stable both-edges; /* Reserve space to prevent layout shift when scrollbar toggles */
} }
@@ -42,10 +39,10 @@ body.modal-open {
min-height: 480px; min-height: 480px;
} }
/* 当 modal 打开时锁定 body */ /* Lock body when modal is open */
body.modal-open { body.modal-open {
overflow: hidden !important; /* 覆盖 base.css 中的 scroll */ overflow: hidden !important;
padding-right: var(--scrollbar-width, 8px); /* 使用滚动条宽度作为补偿 */ padding-right: var(--scrollbar-width, 8px);
} }
@keyframes modalFadeIn { @keyframes modalFadeIn {
@@ -67,12 +64,25 @@ body.modal-open {
} }
.cancel-btn, .delete-btn, .exclude-btn, .confirm-btn { .cancel-btn, .delete-btn, .exclude-btn, .confirm-btn {
padding: 8px var(--space-2); display: flex;
border-radius: 6px; align-items: center;
justify-content: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--border-radius-sm);
border: none; border: none;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
font-size: 0.95em;
min-width: 100px; min-width: 100px;
transition: background-color var(--transition-base), opacity var(--transition-base), transform var(--transition-fast);
}
.cancel-btn:active,
.delete-btn:active,
.exclude-btn:active,
.confirm-btn:active {
transform: scale(0.98);
} }
.cancel-btn { .cancel-btn {
@@ -92,16 +102,20 @@ body.modal-open {
color: white; color: white;
} }
.cancel-btn:hover { .cancel-btn:hover,
.cancel-btn:focus-visible {
background: var(--lora-border); background: var(--lora-border);
} }
.delete-btn:hover { .delete-btn:hover,
opacity: 0.9; .delete-btn:focus-visible {
background: oklch(from var(--lora-error) l c h / 85%);
} }
.exclude-btn:hover, .confirm-btn:hover { .exclude-btn:hover,
opacity: 0.9; .exclude-btn:focus-visible,
.confirm-btn:hover,
.confirm-btn:focus-visible {
background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%); background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%);
} }
@@ -121,47 +135,41 @@ body.modal-open {
font-size: 1.5em; font-size: 1.5em;
cursor: pointer; cursor: pointer;
opacity: 0.7; opacity: 0.7;
transition: opacity 0.2s; transition: opacity var(--transition-base);
z-index: 10; z-index: 10;
} }
.close:hover { .close:hover,
.close:focus-visible {
opacity: 1; opacity: 1;
outline: 2px solid var(--lora-accent);
outline-offset: 2px;
border-radius: var(--border-radius-xs);
} }
/* 统一各个 section 的样式 */ /* Unified section styles */
.support-section, .support-section,
.changelog-section, .changelog-section,
.update-info, .update-info,
.info-item, .info-item,
.path-preview { .path-preview {
background: rgba(0, 0, 0, 0.03); background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
padding: var(--space-2); padding: var(--space-2);
} }
/* 深色主题统一样式 */ /* Dark theme unified styles */
[data-theme="dark"] .modal-content { [data-theme="dark"] .modal-content {
background: var(--lora-surface); background: var(--lora-surface);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
} }
[data-theme="dark"] .support-section,
[data-theme="dark"] .changelog-section,
[data-theme="dark"] .update-info,
[data-theme="dark"] .info-item,
[data-theme="dark"] .path-preview,
[data-theme="dark"] #bulkDownloadMissingLorasModal .bulk-download-loras-preview {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
.primary-btn { .primary-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 16px; padding: var(--space-1) var(--space-2);
background-color: var(--lora-accent); background-color: var(--lora-accent);
color: var(--lora-text); color: var(--lora-text);
border: none; border: none;
@@ -171,9 +179,11 @@ body.modal-open {
font-size: 0.95em; font-size: 0.95em;
} }
.primary-btn:hover { .primary-btn:hover,
.primary-btn:focus-visible {
background-color: oklch(from var(--lora-accent) l c h / 85%); background-color: oklch(from var(--lora-accent) l c h / 85%);
color: var(--lora-text); color: var(--lora-text);
outline: none;
} }
/* Secondary button styles */ /* Secondary button styles */
@@ -181,19 +191,21 @@ body.modal-open {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 16px; padding: var(--space-1) var(--space-2);
background-color: var(--card-bg); background-color: var(--card-bg);
color: var (--text-color); color: var(--text-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: var(--transition-base);
font-size: 0.95em; font-size: 0.95em;
} }
.secondary-btn:hover { .secondary-btn:hover,
.secondary-btn:focus-visible {
background-color: var(--border-color); background-color: var(--border-color);
color: var(--text-color); color: var(--text-color);
outline: none;
} }
/* Disabled button styles */ /* Disabled button styles */
@@ -244,7 +256,7 @@ button:disabled,
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 16px; padding: var(--space-1) var(--space-2);
background-color: var(--lora-error); background-color: var(--lora-error);
color: white; color: white;
border: none; border: none;
@@ -254,25 +266,22 @@ button:disabled,
font-size: 0.95em; font-size: 0.95em;
} }
.danger-btn:hover { .danger-btn:hover,
.danger-btn:focus-visible {
background-color: oklch(from var(--lora-error) l c h / 85%); background-color: oklch(from var(--lora-error) l c h / 85%);
color: white; color: white;
outline: none;
} }
/* Metadata archive status styles */ /* Metadata archive status styles */
.metadata-archive-status { .metadata-archive-status {
background: rgba(0, 0, 0, 0.03); background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
padding: var(--space-2); padding: var(--space-2);
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
[data-theme="dark"] .metadata-archive-status {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
.archive-status-item { .archive-status-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -312,17 +321,12 @@ button:disabled,
} }
.backup-status { .backup-status {
background: rgba(0, 0, 0, 0.03); background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
padding: var(--space-3); padding: var(--space-3);
} }
[data-theme="dark"] .backup-status {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
.backup-summary-grid { .backup-summary-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
@@ -331,17 +335,12 @@ button:disabled,
} }
.backup-summary-card { .backup-summary-card {
background: rgba(255, 255, 255, 0.5); background: var(--lora-surface);
border: 1px solid rgba(0, 0, 0, 0.06); border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
padding: var(--space-2); padding: var(--space-2);
} }
[data-theme="dark"] .backup-summary-card {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.05);
}
.backup-summary-label { .backup-summary-label {
color: var(--text-color); color: var(--text-color);
font-size: 0.85rem; font-size: 0.85rem;
@@ -404,14 +403,9 @@ button:disabled,
} }
.backup-location-details { .backup-location-details {
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
background: rgba(0, 0, 0, 0.02); background: var(--surface-subtle);
}
[data-theme="dark"] .backup-location-details {
border-color: var(--lora-border);
background: rgba(255, 255, 255, 0.02);
} }
.backup-location-details summary { .backup-location-details summary {
@@ -442,16 +436,12 @@ button:disabled,
max-width: 100%; max-width: 100%;
padding: 6px 8px; padding: 6px 8px;
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
background: rgba(0, 0, 0, 0.05); background: var(--surface-subtle);
color: var(--text-color); color: var(--text-color);
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
} }
[data-theme="dark"] .backup-location-path {
background: rgba(255, 255, 255, 0.05);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.backup-status-row { .backup-status-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -519,8 +509,8 @@ button:disabled,
} }
#bulkDownloadMissingLorasModal .bulk-download-loras-preview { #bulkDownloadMissingLorasModal .bulk-download-loras-preview {
background: rgba(0, 0, 0, 0.03); background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
padding: var(--space-3); padding: var(--space-3);
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
@@ -578,7 +568,7 @@ button:disabled,
align-items: flex-start; align-items: flex-start;
gap: var(--space-2); gap: var(--space-2);
padding: var(--space-2); padding: var(--space-2);
background: rgba(59, 130, 246, 0.1); background: oklch(from var(--lora-accent) l c h / 0.1);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
font-size: 0.9em; font-size: 0.9em;
color: var(--text-color); color: var(--text-color);

View File

@@ -33,6 +33,39 @@
animation: modalFadeIn 0.2s ease-out; 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: var(--font-mono);
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, .delete-model-info,
.exclude-model-info { .exclude-model-info {
/* Update info display styling */ /* Update info display styling */

View File

@@ -48,8 +48,7 @@
padding: var(--space-3); padding: var(--space-3);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
background: rgba(0, 0, 0, 0.03); background: var(--surface-subtle);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
} }
.doctor-kicker { .doctor-kicker {
@@ -128,7 +127,7 @@
.doctor-issue-card { .doctor-issue-card {
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(0, 0, 0, 0.03); background: var(--surface-subtle);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
padding: var(--space-3); padding: var(--space-3);
box-shadow: none; box-shadow: none;
@@ -242,7 +241,7 @@
[data-theme="dark"] .doctor-hero, [data-theme="dark"] .doctor-hero,
[data-theme="dark"] .doctor-issue-card { [data-theme="dark"] .doctor-issue-card {
background: rgba(255, 255, 255, 0.03); background: var(--surface-subtle);
border-color: var(--lora-border); border-color: var(--lora-border);
box-shadow: none; box-shadow: none;
} }

View File

@@ -37,7 +37,7 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: var(--transition-base);
background: var(--bg-color); background: var(--bg-color);
margin: 1px; margin: 1px;
position: relative; position: relative;
@@ -45,7 +45,7 @@
.version-item:hover { .version-item:hover {
border-color: var(--lora-accent); border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-md);
z-index: 1; z-index: 1;
} }
@@ -156,7 +156,7 @@
background: var(--bg-color); background: var(--bg-color);
color: var(--text-color); color: var(--text-color);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: var(--transition-base);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -225,7 +225,7 @@
padding: 4px 8px; padding: 4px 8px;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: var(--transition-base);
color: var(--text-color); color: var(--text-color);
opacity: 0.7; opacity: 0.7;
text-decoration: none; text-decoration: none;
@@ -272,7 +272,7 @@
padding: 4px 8px; padding: 4px 8px;
cursor: pointer; cursor: pointer;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
transition: all 0.2s ease; transition: var(--transition-base);
position: relative; position: relative;
} }
@@ -293,7 +293,7 @@
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
border-radius: 2px; border-radius: 2px;
transition: all 0.2s ease; transition: var(--transition-base);
} }
.tree-expand-icon:hover { .tree-expand-icon:hover {
@@ -364,7 +364,7 @@
color: var(--text-color); color: var(--text-color);
cursor: pointer; cursor: pointer;
font-size: 0.8em; font-size: 0.8em;
transition: all 0.2s ease; transition: var(--transition-base);
} }
.create-folder-form button.confirm { .create-folder-form button.confirm {
@@ -404,7 +404,7 @@
.path-display { .path-display {
padding: var(--space-1); padding: var(--space-1);
color: var(--text-color); color: var(--text-color);
font-family: monospace; font-family: var(--font-mono);
font-size: 0.9em; font-size: 0.9em;
line-height: 1.4; line-height: 1.4;
white-space: pre-wrap; white-space: pre-wrap;
@@ -453,7 +453,7 @@
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: var(--border-color); background-color: var(--border-color);
transition: all 0.3s ease; transition: var(--transition-slow);
border-radius: 18px; border-radius: 18px;
} }
@@ -465,9 +465,9 @@
left: 3px; left: 3px;
bottom: 3px; bottom: 3px;
background-color: white; background-color: white;
transition: all 0.3s ease; transition: var(--transition-slow);
border-radius: 50%; border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-xs);
} }
.inline-toggle-container .toggle-switch input:checked+.toggle-slider { .inline-toggle-container .toggle-switch input:checked+.toggle-slider {
@@ -503,3 +503,322 @@
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
} }
/* File Count Badge on Version Items */
.file-select-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
background: oklch(var(--lora-accent) / 0.18);
color: var(--lora-accent);
font-size: inherit;
font-weight: 600;
cursor: pointer;
transition: var(--transition-base);
border: 1px solid oklch(var(--lora-accent) / 0.35);
user-select: none;
box-shadow: 0 1px 2px oklch(var(--lora-accent) / 0.1);
}
.file-select-badge:hover {
background: oklch(var(--lora-accent) / 0.3);
border-color: var(--lora-accent);
transform: scale(1.05);
box-shadow: 0 2px 6px oklch(var(--lora-accent) / 0.2);
}
.file-select-badge:active {
transform: scale(0.98);
}
.file-select-badge i {
font-size: 0.9em;
}
.file-select-badge .badge-arrow {
margin-left: 2px;
font-size: 0.65em;
opacity: 0.7;
}
/* File Selection Step */
.file-selection-header {
margin-bottom: var(--space-3);
}
.file-selection-header h3 {
margin: 0 0 4px 0;
font-size: 1.1em;
color: var(--text-color);
}
.file-selection-version-name {
font-size: 0.9em;
color: var(--text-color);
opacity: 0.7;
}
.file-selection-list {
max-height: 360px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.file-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: var(--transition-base);
background: var(--bg-color);
}
.file-option:hover {
border-color: var(--lora-accent);
box-shadow: var(--shadow-sm);
}
.file-option.selected {
border: 2px solid var(--lora-accent);
background: oklch(var(--lora-accent) / 0.05);
}
.file-option-radio {
flex-shrink: 0;
}
.file-option-radio input[type="radio"] {
width: 16px;
height: 16px;
accent-color: var(--lora-accent);
cursor: pointer;
}
.file-option-info {
flex: 1;
min-width: 0;
}
.file-option-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 4px;
}
.file-tag {
display: inline-block;
padding: 2px 7px;
border-radius: 4px;
font-size: 0.8em;
font-weight: 500;
line-height: 1.4;
}
.file-tag.format {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
}
.file-tag.fp {
background: oklch(0.6 0.15 250 / 0.1);
color: oklch(0.55 0.15 250);
}
.file-tag.size {
background: oklch(0.55 0.1 160 / 0.1);
color: oklch(0.5 0.12 160);
}
.file-option-name {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.file-option-size {
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Dark theme adjustments */
[data-theme="dark"] .file-option {
background: var(--lora-surface);
}
[data-theme="dark"] .file-tag.fp {
background: oklch(0.55 0.12 250 / 0.15);
color: oklch(0.7 0.12 250);
}
[data-theme="dark"] .file-tag.size {
background: oklch(0.5 0.08 160 / 0.15);
color: oklch(0.65 0.08 160);
}
/* Textarea for multi-URL input */
#modelUrl {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-family: var(--font-mono);
font-size: 0.9em;
resize: vertical;
line-height: 1.5;
}
.input-hint {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-color);
opacity: 0.7;
font-size: 0.85em;
margin-top: 6px;
}
.input-hint i {
color: var(--lora-accent);
}
/* Batch Preview List */
.batch-preview-list {
max-height: 400px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
}
.batch-preview-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--bg-color);
}
.batch-preview-item:first-child {
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
}
.batch-preview-item:last-child {
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
}
.batch-preview-item:only-child {
border-radius: var(--border-radius-sm);
}
.batch-preview-thumbnail {
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: var(--border-radius-xs);
overflow: hidden;
background: var(--lora-surface);
}
.batch-preview-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.batch-preview-icon {
width: 48px;
height: 48px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--lora-error);
font-size: 1.2em;
}
.batch-preview-info {
flex: 1;
min-width: 0;
}
.batch-preview-name {
font-weight: 500;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.batch-preview-meta {
display: flex;
gap: 8px;
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
margin-top: 2px;
}
.batch-preview-error-text {
color: var(--lora-error);
opacity: 1;
}
.batch-preview-local-badge {
color: var(--lora-accent);
opacity: 1;
}
.batch-preview-local {
opacity: 0.6;
}
.batch-preview-change-version {
flex-shrink: 0;
font-size: 0.85em;
padding: 4px 10px;
}
.batch-preview-remove {
flex-shrink: 0;
background: none;
border: none;
color: var(--text-color);
opacity: 0.5;
cursor: pointer;
padding: 4px 8px;
font-size: 1em;
}
.batch-preview-remove:hover {
opacity: 1;
color: var(--lora-error);
}
.batch-preview-error {
background: oklch(0.5 0.15 25 / 0.05);
}
[data-theme="dark"] .batch-preview-item {
background: var(--lora-surface);
}

View File

@@ -20,12 +20,12 @@
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
background-color: var(--lora-surface); background-color: var(--lora-surface);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: var(--transition-base);
} }
.example-option-btn:hover { .example-option-btn:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-md);
border-color: var(--lora-accent); border-color: var(--lora-accent);
} }
@@ -68,5 +68,5 @@
/* Dark theme adjustments */ /* Dark theme adjustments */
[data-theme="dark"] .example-option-btn:hover { [data-theme="dark"] .example-option-btn:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); box-shadow: var(--shadow-elevated);
} }

View File

@@ -32,7 +32,7 @@
color: var(--text-color); color: var(--text-color);
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
transition: all 0.2s; transition: var(--transition-base);
opacity: 0.7; opacity: 0.7;
} }
@@ -150,7 +150,7 @@
margin-left: 8px; margin-left: 8px;
vertical-align: middle; vertical-align: middle;
animation: fadeIn 0.5s ease-in-out; animation: fadeIn 0.5s ease-in-out;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-sm);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
@@ -164,7 +164,7 @@
/* Dark theme adjustments for new content badge */ /* Dark theme adjustments for new content badge */
[data-theme="dark"] .new-content-badge { [data-theme="dark"] .new-content-badge {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); box-shadow: var(--shadow-lg);
} }
/* Update video list styles */ /* Update video list styles */
@@ -210,7 +210,7 @@
margin-left: 10px; margin-left: 10px;
vertical-align: middle; vertical-align: middle;
animation: fadeIn 0.5s ease-in-out; animation: fadeIn 0.5s ease-in-out;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-md);
} }
.update-date-badge i { .update-date-badge i {
@@ -225,7 +225,7 @@
/* Dark theme adjustments */ /* Dark theme adjustments */
[data-theme="dark"] .update-date-badge { [data-theme="dark"] .update-date-badge {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); box-shadow: var(--shadow-md);
} }
/* Privacy-friendly video embed styles */ /* Privacy-friendly video embed styles */
@@ -281,7 +281,7 @@
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: var(--transition-base);
background-color: var(--lora-accent); background-color: var(--lora-accent);
color: white; color: white;
text-decoration: none; text-decoration: none;
@@ -303,5 +303,5 @@
/* Dark theme adjustments */ /* Dark theme adjustments */
[data-theme="dark"] .video-container { [data-theme="dark"] .video-container {
background-color: rgba(255, 255, 255, 0.03); background-color: var(--surface-hover);
} }

View File

@@ -10,7 +10,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: var(--transition-base);
} }
.settings-toggle:hover { .settings-toggle:hover {
@@ -81,7 +81,7 @@
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
transition: all 0.2s ease; transition: var(--transition-base);
margin-bottom: 4px; margin-bottom: 4px;
} }
@@ -154,7 +154,7 @@
background-color: var(--lora-surface); background-color: var(--lora-surface);
color: var(--text-color); color: var(--text-color);
font-size: 0.9em; font-size: 0.9em;
transition: all 0.2s ease; transition: var(--transition-base);
} }
.settings-search-input:focus { .settings-search-input:focus {
@@ -183,7 +183,7 @@
justify-content: center; justify-content: center;
font-size: 0.7em; font-size: 0.7em;
opacity: 0.6; opacity: 0.6;
transition: all 0.2s ease; transition: var(--transition-base);
} }
.settings-search-clear:hover { .settings-search-clear:hover {
@@ -289,7 +289,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: var(--transition-base);
text-decoration: none; text-decoration: none;
position: relative; position: relative;
} }
@@ -582,7 +582,7 @@
} }
.priority-tags-example code { .priority-tags-example code {
font-family: var(--code-font, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace); font-family: var(--font-mono);
background-color: rgba(var(--lora-accent-rgb, 79, 70, 229), 0.12); background-color: rgba(var(--lora-accent-rgb, 79, 70, 229), 0.12);
padding: 2px 6px; padding: 2px 6px;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
@@ -614,7 +614,7 @@
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
color: var(--text-color); color: var(--text-color);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: var(--transition-base);
opacity: 0.7; opacity: 0.7;
} }
@@ -927,19 +927,19 @@ input:checked + .toggle-slider:before {
/* Path Template Settings Styles */ /* Path Template Settings Styles */
.template-preview { .template-preview {
background: rgba(0, 0, 0, 0.03); background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: var(--space-1); padding: var(--space-1);
margin-top: 8px; margin-top: 8px;
font-family: monospace; font-family: var(--font-mono);
font-size: 0.9em; font-size: 0.9em;
color: var(--lora-accent); color: var(--lora-accent);
display: none; display: none;
} }
[data-theme="dark"] .template-preview { [data-theme="dark"] .template-preview {
background: rgba(255, 255, 255, 0.03); background: var(--surface-subtle);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
} }
@@ -974,7 +974,7 @@ input:checked + .toggle-slider:before {
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
cursor: pointer; cursor: pointer;
font-size: 0.9em; font-size: 0.9em;
transition: all 0.2s; transition: var(--transition-base);
height: 32px; /* Match other control heights */ height: 32px; /* Match other control heights */
} }
@@ -1030,7 +1030,7 @@ input:checked + .toggle-slider:before {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s; transition: var(--transition-base);
} }
.remove-mapping-btn:hover { .remove-mapping-btn:hover {
@@ -1146,7 +1146,7 @@ input:checked + .toggle-slider:before {
color: white; color: white;
padding: 2px 6px; padding: 2px 6px;
border-radius: 3px; border-radius: 3px;
font-family: monospace; font-family: var(--font-mono);
font-size: 1em; font-size: 1em;
font-weight: 500; font-weight: 500;
} }
@@ -1175,7 +1175,7 @@ input:checked + .toggle-slider:before {
background-color: var(--lora-surface); background-color: var(--lora-surface);
color: var(--text-color); color: var(--text-color);
font-size: 0.95em; font-size: 0.95em;
font-family: monospace; font-family: var(--font-mono);
height: 24px; height: 24px;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
@@ -1277,7 +1277,7 @@ input:checked + .toggle-slider:before {
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;
font-weight: normal; font-weight: normal;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; font-family: var(--font-body);
white-space: normal; white-space: normal;
max-width: 220px; max-width: 220px;
width: max-content; width: max-content;
@@ -1287,7 +1287,7 @@ input:checked + .toggle-slider:before {
pointer-events: none; pointer-events: none;
z-index: 10000; z-index: 10000;
line-height: 1.4; line-height: 1.4;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: var(--shadow-elevated);
text-transform: none; text-transform: none;
} }
@@ -1309,7 +1309,7 @@ input:checked + .toggle-slider:before {
/* Dark theme adjustments for tooltip - Fully opaque */ /* Dark theme adjustments for tooltip - Fully opaque */
[data-theme="dark"] .info-icon[data-tooltip]::after { [data-theme="dark"] .info-icon[data-tooltip]::after {
background: rgba(40, 40, 40, 0.95); background: rgba(40, 40, 40, 0.95);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); box-shadow: var(--shadow-dark-lg);
} }
/* Extra Folder Paths - Single input layout */ /* Extra Folder Paths - Single input layout */
@@ -1361,7 +1361,7 @@ input:checked + .toggle-slider:before {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s; transition: var(--transition-base);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1369,3 +1369,14 @@ input:checked + .toggle-slider:before {
background: var(--lora-error); background: var(--lora-error);
color: white; 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);
}

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