Compare commits

...

102 Commits

Author SHA1 Message Date
Will Miao
999814ca87 chore(release): bump version to v1.1.4 2026-06-19 18:31:03 +08:00
Will Miao
3c2760a803 fix(stats): sort Base Model Distribution X-axis labels alphabetically (#796) 2026-06-19 17:29:33 +08:00
Will Miao
0edbd7bcca fix(metadata): add LoraTextLoaderLM extractor so SaveImageLM records its loras (#801) 2026-06-19 17:13:48 +08:00
Will Miao
21e89fa7de fix(tags): normalize tag case on save and make filtering case-insensitive (#727)
- save_metadata_updates now trims/lowercases/dedupes tags on write
- ModelFilterSet tag matching is now case-insensitive (both include/exclude)
- Removed redundant .lower() calls in tag_update_service.py
2026-06-19 16:42:09 +08:00
Will Miao
968d6d1d1f feat(tags): unify recipe modal tag UI with model modal
- Replace recipe modal's custom tag display/edit with shared
  renderCompactTags/setupTagEditMode from ModelTags and utils
- Remove 300+ lines of duplicated tag display and editing code
- Parameterize setupTagEditMode with saveHandler/onSaved/showSuggestions
  options for recipe-specific save flow (updateRecipeMetadata + dirty state)
- Scope all DOM queries in ModelTags.js via options.container / this.closest
  to prevent cross-modal element conflicts
- Fix edit button alignment (justify-content: flex-start)
- Fix tag tooltip selector scoping in setupTagTooltip
- Add width: 100% to #recipeTagsContainer for edit container full width
2026-06-19 16:31:27 +08:00
Will Miao
cf0fd0e0ad feat(i18n): internationalize dynamic insights content with key/params architecture (#489) 2026-06-19 13:49:03 +08:00
Will Miao
16e5dcf7b2 feat(i18n): internationalize statistics page strings across all locales 2026-06-19 13:37:01 +08:00
Will Miao
ab6bb25d46 fix(example-images): skip hidden files in path validation, show offending items on failure (#807) 2026-06-19 11:54:55 +08:00
Will Miao
07f49559be fix(virtual-scroll): avoid full reload on move-to-folder, scroll to top on filter/page reset
- MoveManager/SidebarManager: replace resetAndReload with in-place
  VirtualScroller update after move operations (remove non-visible,
  update visible items' file_path). Preserves scroll position and
  avoids empty grid.
- VirtualScroller: add removeMultipleItemsByFilePath for efficient
  batch removal with Array.isArray guard.
- baseModelApi: scroll to top on loadMoreWithVirtualScroll(true),
  covering filter/sort/search/folder/views changes.
- SidebarManager selectFolder: scroll now handled centrally.
2026-06-19 09:18:49 +08:00
Will Miao
b24b1a7e57 feat(settings): hide API key from frontend, use status+edit instead of password field
Backend changes:
- Add civitai_api_key to _NO_SYNC_KEYS, return only boolean civitai_api_key_set
- Clean up known template placeholder on load to prevent false positive

Frontend changes:
- Replace type=password with type=text + CSS masking (-webkit-text-security)
- Replace pre-filled input with status display (Configured/Not configured)
- Add inline edit view with Save/Cancel buttons
- Re-add eye toggle via CSS class toggle (not type switching)
- Use CSS transitions for smooth status/edit view switching

This prevents Chromium/Vivaldi password manager from triggering
'save password' prompts when opening the settings modal.
2026-06-19 08:05:04 +08:00
Will Miao
faf64f8986 fix(css): migrate duplicates component to canonical color tokens
Replace undefined --lora-accent-l/c/h and --lora-warning-l/c/h with
canonical --color-accent-l/c/h and --color-warning-l/c/h from the
design token system. Fix 5 border-color declarations missing oklch()
wrapper, fix var() space syntax error in .group-toggle-btn:hover,
and replace hardcoded green with --color-success token.
2026-06-18 22:41:46 +08:00
Will Miao
a617487a43 fix(ui): lift theme popover out of header stacking context to appear above modals 2026-06-18 22:19:36 +08:00
Will Miao
3012a7aef3 fix(settings): prevent Firefox save-password prompt from API key input
- Remove server-side value='...' from password field in settings modal template
  so the API key is never baked into the DOM at page load time
- Populate the input dynamically via loadSettingsToUI() when modal opens
- Clear both API key and proxy password fields on modal close to prevent
  Firefox from detecting pre-filled password fields on page navigation
2026-06-18 21:57:03 +08:00
Will Miao
499e19de34 fix(modals): tone down batch summary modal styling - remove icons, flatten gradients, lock to design tokens
- Metadata Fetch Summary: remove per-card icons, demote total/duration cards
  to neutral border, drop title icon, fix table header border width
- Batch Import Summary: replace 3em centered hero with inline left-aligned
  layout, flatten progress bar gradient, simplify circular badges to plain
  colored icons, unify border widths to 4px and token namespace to --color-
- Lock all off-scale em typography to --text-{xs,lg} design tokens
2026-06-18 21:56:58 +08:00
Will Miao
9161762ca9 fix(sidebar): align hidden indicator height (48px) and icon size with sidebar header 2026-06-18 21:14:35 +08:00
Will Miao
9bbd26efe6 feat(license-icons): add second set of license icons matching current CivitAI design
- Add 5 new Tabler SVG icons (currency-dollar, brush, user, git-merge, license)
- Implement Set 2 rendering in ModelModal.js (standalone UI) with green/red
  permission indicators and preview_tooltip.js (ComfyUI widget)
- Add use_new_license_icons setting (default: true) with toggle in settings UI
- ComfyUI tooltip reads setting directly from preview-url API response to
  eliminate race conditions and respect standalone settings changes
- Remove the now-unused separate ComfyUI setting loramanager.license_icon_style
- Add CSS for both standalone (lora-modal.css) and widget (lm_styles.css)
- i18n: translate licenseIcons keys into all 10 supported languages
- Fix test to use classic style explicitly for continued coverage
2026-06-18 21:07:44 +08:00
Will Miao
258b2622d5 fix(sidebar): align restore indicator with sidebar header and add first-use breathing animation (#990) 2026-06-18 19:22:38 +08:00
Will Miao
80ec9085dd fix(theme): replace Gruvbox with Midnight, fix accent/info hue collisions and hardcoded colors
- Replace Gruvbox preset with Midnight (deep blue-purple, violet accent)
- Fix accent/info hue collisions in Nord, Monokai, Dracula, Solarized
- Fix Solarized error/warning collision (error-h 25->5) and WCAG contrast
- Make --color-skip-refresh-* follow --color-warning-h dynamically
- Replace hardcoded rgba(24,144,255) in onboarding.css with --color-accent
- Replace hardcoded #00B87A in import modals with --color-success
2026-06-18 18:57:53 +08:00
Will Miao
c5c7373e10 feat(theme): add 5 preset color themes (Nord/Gruvbox/Monokai/Dracula/Solarized) with popover selector
Implements Approach C (dual-attribute: data-theme + data-theme-preset),
keeping all 106 existing [data-theme="dark"] overrides unchanged.

- Colors: 5 professionally designed oklch palettes in tokens/colors.css
- UI: popover theme selector with mode (Light/Dark/Auto) + preset grid
- JS: cycleTheme(), setPreset(), localStorage persistence
- Locale: 12 new translation keys across 10 languages
- Polish: solid accent swatches matching flat token-driven aesthetic
2026-06-18 09:53:40 +08:00
Will Miao
b7721866e5 fix(stats): implement Model Types chart in Collection tab with correct type distribution 2026-06-18 06:48:46 +08:00
Will Miao
8314b9bedb feat(downloads): add /downloads/queue/status endpoint and integrate queue lifecycle
- New GET /api/lm/downloads/queue/status handler for non-terminal status
  transitions (queued -> downloading, downloading -> paused, etc.)
- Queue lifecycle auto-integration in DownloadManager._download_with_semaphore:
  downloading -> SQLite update_status('downloading') on semaphore acquire
  completed -> complete_download('completed') on success
  canceled -> complete_download('canceled') on CancelledError
  failed -> complete_download('failed') on Exception
- All queue operations wrapped in try/except to never break the download flow
2026-06-17 23:04:30 +08:00
Will Miao
75298a402f chore(release): bump version to v1.1.3 2026-06-17 17:52:56 +08:00
Will Miao
92b5efd414 fix: guard posix_fadvise on non-Linux platforms to prevent AttributeError on Windows (#988) 2026-06-17 17:22:10 +08:00
Will Miao
33ee392b7b feat(settings): redesign Card Overlay Blur range slider to match settings UI style 2026-06-17 15:24:14 +08:00
Will Miao
5237f8b7dc chore: remove keyboard navigation UI elements and related code
- Delete static/css/components/keyboard-nav.css entirely
- Remove @import of keyboard-nav.css from style.css
- Remove keyboard-nav-hint divs from controls.html and recipes.html
- Clean up all keyboard.* translation keys from 10 locale files

The actual keyboard scrolling handlers (PageUp/PageDown in infiniteScroll.js
and VirtualScroller.js) are kept as they provide core scroll functionality.
2026-06-17 15:07:34 +08:00
Will Miao
5107313fd1 revert: restore &logo=github parameter to release-date badge
This reverts commit 95bbc669efb1aa0c23b94be6f0a5e7a188f1c019.

The real issue was shields.io GitHub API token pool exhaustion (intermittent),
not the &logo=github parameter. All 3 badges (Discord, Release, Release Date)
were affected at various times due to the same root cause: shields.io
temporarily unable to query GitHub API.
2026-06-17 11:24:40 +08:00
Will Miao
95bbc66919 fix: remove broken logo parameter from release-date badge URL 2026-06-17 11:21:26 +08:00
Will Miao
e268e59419 chore: stop tracking .docs/ and add to .gitignore
.docs/ is now excluded from git tracking so working/research notes
can live there without being committed.
2026-06-17 11:20:19 +08:00
willmiao
547e1f9498 docs: auto-update supporters list in README 2026-06-17 01:57:52 +00:00
Will Miao
bf32d8b6fd chore(release): bump version to v1.1.2 2026-06-17 09:57:37 +08:00
Will Miao
8299881024 refactor(sidebar): remove pin/unpin and global hide, use per-page hide only
- Remove pin/unpin and auto-hide hover mechanism (isPinned, isHovering,
  hoverTimeout, showSidebar/hideSidebar, updateAutoHideState, etc.)
- Remove global show_folder_sidebar setting (SettingsManager,
  PageControls, recipes, backend default)
- Simplify sidebar visibility to a single per-page toggle:
  · Dedicated chevron-left button in header to hide sidebar
  · Edge indicator (chevron-right) to restore when hidden
  · No dropdown, no hover area, no pin button
- Add _migrateOldSettings() to convert old sidebarPinned and
  show_folder_sidebar states to per-page sidebarDisabled
- Fix sidebar flicker on page load: CSS defaults to off-screen,
  JS explicitly sets .visible or .hidden-by-setting
- Remove obsolete CSS classes: auto-hide, hover-active, collapsed
- Remove i18n keys: pinSidebar, unpinSidebar, moreOptions
- Update test mocks for the new initialize() interface
2026-06-17 09:49:24 +08:00
Will Miao
da02268196 fix(css): add top margin to stat-cards container for consistent spacing 2026-06-17 08:24:03 +08:00
Will Miao
8c4b9a1e70 fix(metadata-sync): persist not-found flags to SQLite cache on deleted-provider path
When a model is already classified as civitai_deleted=True via
.metadata.json but re-enters the failure block through the
civarchive/sqlite provider path (not the default provider),
needs_save was never set to True because civitai_api_not_found
and sqlite_attempted were both False. The flags were never
persisted to SQLite, causing the model to be re-fetched on
every restart.

Also demoted duplicate INFO/ERROR logging in fetch_and_update_model
to DEBUG (the use case already logs at WARNING), and added
exc_info=True to the fetch_all_civitai error handler.
2026-06-17 08:22:24 +08:00
Will Miao
0906c484e9 fix: actually halt bulk operations on cancel — frontend AbortController + backend guards (#986) 2026-06-17 07:20:32 +08:00
Will Miao
4199c30fec fix(metadata-sync): downgrade "Model not found" to INFO and replace model_name with file+sha256 in log 2026-06-17 00:06:43 +08:00
Will Miao
4a8084cdbc feat(save-image): support %NodeTitle.WidgetName% placeholders and fix %seed% None fallback (#314) 2026-06-16 23:48:44 +08:00
Will Miao
6263e6848c fix: move posix_fadvise(DONTNEED) after read loop so it actually evicts pages (#985) 2026-06-16 23:12:02 +08:00
Will Miao
58c266ad07 fix(scanner): respect lazy hash for checkpoints, add posix_fadvise, cancel on shutdown (#985) 2026-06-16 23:00:23 +08:00
Will Miao
2939813e1a feat(metadata-fetch): add result summary modal with i18n, fix contrast and counting bugs (#38) 2026-06-16 22:38:50 +08:00
Will Miao
a9e5ee7e79 fix: follow-up nits for AVIF/JXL brotli support
- Fix JXL container ftyp size check (==20 → >=16) to accept
  wider range of valid JXL files
- Add brotli decompression size limit (2 MB) to prevent OOM
- Add trailing newline to requirements.txt
- Add unit tests for new ISOBMFF/brotli extraction paths:
  JXL/AVIF happy paths, missing brob, corrupt payload,
  non-ISOBMFF fallthrough, write-skip on AVIF/JXL,
  JSON dict/list fields, and oversized decompression
2026-06-16 16:27:56 +08:00
Will Miao
a17b0e9901 Merge pull request #982 from koloved/main
Add AVIF and JXL image support with brotli metadata decompression
2026-06-16 16:24:30 +08:00
s.ivanov
8f23d966bf Update requirements.txt 2026-06-16 07:27:32 +02:00
Will Miao
7a76fc72d0 fix(rate-limit): continue to next provider on CivArchive 429 to prevent bulk refresh from freezing (#983)
When CivArchive returns HTTP 429 with a large retry_after, the bulk
metadata refresh would block for hours because:

1. FallbackMetadataProvider raised RateLimitError instead of continuing
   to the next provider (e.g., SQLite archive was never reached).

2. _RateLimitRetryHelper retried long-rate-limit 429s 3 times — all
   futile since the hourly cap hasn't reset.

3. The batch loop had no awareness of persistent rate-limiting,
   causing 192+ models to each hammer the same rate-limited endpoint.

Changes:
- FallbackMetadataProvider: all 6 methods now continue to next provider
  on RateLimitError instead of raising (model_metadata_provider.py)
- fetch_and_update_model: deleted-model path also continues on
  RateLimitError so sqlite provider gets a chance (metadata_sync_service.py)
- _RateLimitRetryHelper: when retry_after >= 120s, only 1 attempt is
  made — retries are futile for hour-scale rate limits
- BulkMetadataRefreshUseCase: tracks consecutive rate-limit failures
  and aborts early after 3 (bulk_metadata_refresh_use_case.py)

Tests: updated test_fallback_respects_retry_limit for new continue
behavior; added tests for large/small retry_after thresholds.
2026-06-16 13:08:34 +08:00
Will Miao
518a4dd5ee chore: add reasonix.toml and .codegraph/ to .gitignore 2026-06-16 13:05:11 +08:00
s.ivanov
2b6d4e5d8b Add AVIF and JXL image support with brotli metadata decompression 2026-06-15 09:28:49 +02:00
Will Miao
1f4edbeb9d chore(release): bump version to v1.1.1 2026-06-14 23:49:44 +08:00
Will Miao
a256558a0e fix(downloads): delete history entries on retry and add dedup for bug #980
- retry_from_history() and retry_all_failed() now DELETE the original
  history entry after re-queuing it. Previously the old entry stayed
  in history causing exponential growth on repeated retry→cancel→retry
  cycles.
- Add deduplicate() called once on singleton creation to clean up
  existing duplicate queue/history entries left by the bug:
  1. In-status dedup (keep highest id per model+version+status)
  2. Cross-status dedup (prefer completed > failed > canceled)
  3. Queue dedup (keep highest rowid per model+version)
  4. Orphan queue cleanup (source='retry' entries obsoleted by
     terminal history entries)
2026-06-14 22:52:44 +08:00
Will Miao
818b9113f0 fix(preview): add Cache-Control header to FileResponse for browser caching (#975)
Chrome does not cache 206 Partial Content responses for <video> elements
without an explicit Cache-Control header. When VirtualScroller recycles
cards and creates new <video> elements with the same URL, Chrome
re-downloads the full video (several MB each) instead of using the cache.

Verified via Chrome DevTools: same .mp4 URL appears 2-3 times in network
trace as separate requests with no cache hit, each returning 206. With
Cache-Control: max-age=86400, the browser will reuse the cached response
for 24 hours across scroll cycles.

Video preview files are ~3.5MB while image previews are ~50-100KB (due
to WebP optimization), making caching especially impactful for videos.
2026-06-14 17:36:59 +08:00
Will Miao
6a4fd020dc fix(api): return JSON error responses for all /api/* routes — prevent JSON.parse crashes on 404/500 2026-06-14 13:13:01 +08:00
Will Miao
7a23040452 fix(save-image): sanitize invalid filename chars from %pprompt%, %nprompt%, %model% patterns (#978) 2026-06-14 09:33:12 +08:00
Will Miao
138024aefe fix(preview): revert to FileResponse as default for all platforms (#975)
The previous commit (a19ddc14) restored Linux sendfile but kept the
manual streaming path for Windows via sys.platform guard. A Windows
user reports performance is still worse than v1.0.5.

Switch back to web.FileResponse for all files on all platforms as the
default. The IOCP crash is an edge case (fast scrolling through many
video previews) that affects few users, while the Python chunked I/O
performance penalty affects everyone.

_stream_file() is kept as an unused fallback for a future compat
setting toggle.
2026-06-13 21:43:44 +08:00
Will Miao
a19ddc14f6 perf(preview): restore Linux sendfile, add cache headers, increase chunk size (#975)
- Restrict manual video streaming to Windows only (sys.platform == 'win32');
  Linux/macOS now uses kernel sendfile (zero-copy DMA) via aiohttp FileResponse
- Add Cache-Control: public, max-age=86400 to streaming responses so browsers
  cache video previews across scroll cycles
- Increase chunk size from 256KB to 1MB to reduce async iteration overhead on
  Windows where streaming is still required
2026-06-13 20:06:58 +08:00
Will Miao
7001ced694 fix(rate-limit): respect server retry_after instead of capping at 30s 2026-06-13 18:01:13 +08:00
pixelpaws
a5c861646c Merge pull request #974 from itkitteh/fix/socks-proxy-support
fix: support SOCKS proxies for outbound requests
2026-06-13 14:15:02 +08:00
Artem Yakimenko
3e0bb73793 fix: support SOCKS proxies for outbound requests
The proxy settings allow selecting a SOCKS proxy type, but the SOCKS
URL was passed to aiohttp's per-request `proxy=` argument, which only
supports http(s) proxies. With a SOCKS proxy this opens a plain TCP
connection to the proxy port and sends an HTTP request; the SOCKS
server replies with its handshake bytes (e.g. b"\x05\xff") and aiohttp
fails with "Bad status line ... Expected HTTP/, RTSP/ or ICE/".

Route SOCKS proxy types through an aiohttp-socks ProxyConnector on the
session instead, leaving the `proxy=` kwarg for http(s) proxies only.
trust_env now keys off whether an app-level proxy is active. Adds
aiohttp-socks to requirements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:05:15 +10:00
Will Miao
ac51f6a2f6 feat(settings): add adjustable card overlay blur setting (#973) 2026-06-13 09:43:49 +08:00
Will Miao
bef222c77d perf(recipe): precompute image_id_map for O(1) CivitAI image existence checks
Build a civitai_image_id → recipe_id mapping once during cache
initialization instead of scanning all recipes on every
check_image_exists and import_from_url call.

- RecipeCache gains an image_id_map field populated by
  _build_image_id_map() during cache init
- check_image_exists and import_from_url duplicate detection
  now use the precomputed map (O(k) / O(1) vs O(n))
- Map is persisted in SQLite cache_metadata for fast startup
- Incrementally updated on add/remove/bulk_remove paths
- Fix: conn.close() before cache_metadata query (dead connection)
2026-06-13 08:32:03 +08:00
Will Miao
7cd6a53447 fix(downloads): accept optional completed_at in complete_download to preserve original timestamps 2026-06-13 07:06:59 +08:00
willmiao
6850b35770 docs: auto-update supporters list in README 2026-06-12 15:38:33 +00:00
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
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
179 changed files with 10643 additions and 2970 deletions

View File

@@ -1,153 +0,0 @@
# Recipe Batch Import Feature Design
## Overview
Enable users to import multiple images as recipes in a single operation, rather than processing them individually. This feature addresses the need for efficient bulk recipe creation from existing image collections.
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend │
├─────────────────────────────────────────────────────────────────┤
│ BatchImportManager.js │
│ ├── InputCollector (收集URL列表/目录路径) │
│ ├── ConcurrencyController (自适应并发控制) │
│ ├── ProgressTracker (进度追踪) │
│ └── ResultAggregator (结果汇总) │
├─────────────────────────────────────────────────────────────────┤
│ batch_import_modal.html │
│ └── 批量导入UI组件 │
├─────────────────────────────────────────────────────────────────┤
│ batch_import_progress.css │
│ └── 进度显示样式 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Backend │
├─────────────────────────────────────────────────────────────────┤
│ py/routes/handlers/recipe_handlers.py │
│ ├── start_batch_import() - 启动批量导入 │
│ ├── get_batch_import_progress() - 查询进度 │
│ └── cancel_batch_import() - 取消导入 │
├─────────────────────────────────────────────────────────────────┤
│ py/services/batch_import_service.py │
│ ├── 自适应并发执行 │
│ ├── 结果汇总 │
│ └── WebSocket进度广播 │
└─────────────────────────────────────────────────────────────────┘
```
## API Endpoints
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/lm/recipes/batch-import/start` | POST | 启动批量导入,返回 operation_id |
| `/api/lm/recipes/batch-import/progress` | GET | 查询进度状态 |
| `/api/lm/recipes/batch-import/cancel` | POST | 取消导入 |
## Backend Implementation Details
### BatchImportService
Location: `py/services/batch_import_service.py`
Key classes:
- `BatchImportItem`: Dataclass for individual import item
- `BatchImportProgress`: Dataclass for tracking progress
- `BatchImportService`: Main service class
Features:
- Adaptive concurrency control (adjusts based on success/failure rate)
- WebSocket progress broadcasting
- Graceful error handling (individual failures don't stop the batch)
- Result aggregation
### WebSocket Message Format
```json
{
"type": "batch_import_progress",
"operation_id": "xxx",
"total": 50,
"completed": 23,
"success": 21,
"failed": 2,
"skipped": 0,
"current_item": "image_024.png",
"status": "running"
}
```
### Input Types
1. **URL List**: Array of URLs (http/https)
2. **Local Paths**: Array of local file paths
3. **Directory**: Path to directory with optional recursive flag
### Error Handling
- Invalid URLs/paths: Skip and record error
- Download failures: Record error, continue
- Metadata extraction failures: Mark as "no metadata"
- Duplicate detection: Option to skip duplicates
## Frontend Implementation Details (TODO)
### UI Components
1. **BatchImportModal**: Main modal with tabs for URLs/Directory input
2. **ProgressDisplay**: Real-time progress bar and status
3. **ResultsSummary**: Final results with success/failure breakdown
### Adaptive Concurrency Controller
```javascript
class AdaptiveConcurrencyController {
constructor(options = {}) {
this.minConcurrency = options.minConcurrency || 1;
this.maxConcurrency = options.maxConcurrency || 5;
this.currentConcurrency = options.initialConcurrency || 3;
}
adjustConcurrency(taskDuration, success) {
if (success && taskDuration < 1000 && this.currentConcurrency < this.maxConcurrency) {
this.currentConcurrency = Math.min(this.currentConcurrency + 1, this.maxConcurrency);
}
if (!success || taskDuration > 10000) {
this.currentConcurrency = Math.max(this.currentConcurrency - 1, this.minConcurrency);
}
return this.currentConcurrency;
}
}
```
## File Structure
```
Backend (implemented):
├── py/services/batch_import_service.py # 后端服务
├── py/routes/handlers/batch_import_handler.py # API处理器 (added to recipe_handlers.py)
├── tests/services/test_batch_import_service.py # 单元测试
└── tests/routes/test_batch_import_routes.py # API集成测试
Frontend (TODO):
├── static/js/managers/BatchImportManager.js # 主管理器
├── static/js/managers/batch/ # 子模块
│ ├── ConcurrencyController.js # 并发控制
│ ├── ProgressTracker.js # 进度追踪
│ └── ResultAggregator.js # 结果汇总
├── static/css/components/batch-import-modal.css # 样式
└── templates/components/batch_import_modal.html # Modal模板
```
## Implementation Status
- [x] Backend BatchImportService
- [x] Backend API handlers
- [x] WebSocket progress broadcasting
- [x] Unit tests
- [x] Integration tests
- [ ] Frontend BatchImportManager
- [ ] Frontend UI components
- [ ] E2E tests

7
.gitignore vendored
View File

@@ -12,12 +12,14 @@ coverage/
.coverage
model_cache/
# agent
# agent / dev tooling
.opencode/
.claude/
.sisyphus/
.codex
.omo
reasonix.toml
.codegraph/
# Vue widgets development cache (but keep build output)
vue-widgets/node_modules/
@@ -26,3 +28,6 @@ vue-widgets/dist/
# Hypothesis test cache
.hypothesis/
# Working/research notes (not committed)
.docs/

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

View File

@@ -6,62 +6,87 @@
"Scott R"
],
"allSupporters": [
"Insomnia Art Designs",
"2018cfh",
"megakirbs",
"Brennok",
"W+K+White",
"wackop",
"Phil",
"Carl G.",
"Insomnia Art Designs",
"2018cfh",
"Arlecchino Shion",
"Charles Blakemore",
"Rob Williams",
"stone9k",
"itismyelement",
"W+K+White",
"$MetaSamsara",
"onesecondinosaur",
"wackop",
"Phil",
"Carl G.",
"stone9k",
"Rosenthal",
"itismyelement",
"Mozzel",
"Gingko Biloba",
"Kiba",
"onesecondinosaur",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"ClockDaemon",
"Francisco Tatis",
"Tobi_Swagg",
"SG",
"jmack",
"Andrew Wilson",
"Greybush",
"Ricky Carter",
"JongWon Han",
"VantAI",
"レプサイ",
"Michael Wong",
"runte3221",
"Illrigger",
"Tom Corrigan",
"JackieWang",
"FreelancerZ",
"fnkylove",
"Echo",
"Lilleman",
"Robert Stacey",
"PM",
"Edgar Tejeda",
"Fraser Cross",
"Liam MacDougal",
"Polymorphic Indeterminate",
"Sterilized",
"JORGE+LUIZ+HUSSNI+MESSIAS",
"Marc Whiffen",
"Skalabananen",
"Birdy",
"Kiba",
"Mozzel",
"Gingko Biloba",
"quarz",
"Reno Lam",
"JSST",
"sig",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"J\\B/ 8r0wns0n",
"Snaggwort",
"Takkan",
"Matt+J",
"ClockDaemon",
"Baekdoosixt",
"Jonathan Ross",
"KD",
"Omnidex",
"Nazono_hito",
"Melville Parrish",
"daniel dove",
"Lustre",
"Tyler Trebuchon",
"Release Cabrakan",
"SG",
"JW Sin",
"Alex",
"bh",
"carozzz",
"Marlon Daniels",
"James Dooley",
"zenbound",
"Buzzard",
"jmack",
"Aaron Bleuer",
"Adam Shaw",
"Mark Corneglio",
"SarcasticHashtag",
@@ -70,144 +95,130 @@
"RedrockVP",
"Wolffen",
"James Todd",
"Steven Pfeiffer",
"Tim",
"Timmy",
"Johnny",
"Lisster",
"Michael Wong",
"Illrigger",
"whudunit",
"Tom Corrigan",
"JackieWang",
"fnkylove",
"Yushio",
"Vik71it",
"Echo",
"Lilleman",
"Robert Stacey",
"PM",
"Todd Keck",
"Briton Heilbrun",
"Jorge Hussni",
"Sterilized",
"BadassArabianMofo",
"Pascal Dahle",
"quarz",
"Greg",
"JSST",
"lmsupporter",
"zounic",
"wfpearl",
"Baekdoosixt",
"Jonathan Ross",
"Jack B Nimble",
"Nazono_hito",
"Melville Parrish",
"daniel dove",
"Lustre",
"JW Sin",
"contrite831",
"Alex",
"bh",
"Marlon Daniels",
"Starkselle",
"Aaron Bleuer",
"LacesOut!",
"greebles",
"M Postkasse",
"Gooohokrbe",
"Wicked Choices by ASLPro3D",
"OldBones",
"Jacob Hoehler",
"FinalyFree",
"Weasyl",
"Lex Song",
"Cory Paza",
"Steven Pfeiffer",
"Timmy",
"Johnny",
"Tak",
"Gonzalo Andre Allendes Lopez",
"Zach Gonser",
"Lisster",
"Big Red",
"Jimmy Ledbetter",
"whudunit",
"Luc Job",
"dl0901dm",
"Philip Hempel",
"corde",
"Nick Walker",
"nwalker94",
"Yushio",
"Vik71it",
"Bishoujoker",
"aai",
"Todd Keck",
"Briton Heilbrun",
"Tori",
"wildnut",
"jean jahren",
"Aleksander Wujczyk",
"AM Kuro",
"BadassArabianMofo",
"Pascal Dahle",
"Greg",
"Sangheili460",
"MagnaInsomnia",
"Akira_HentAI",
"lmsupporter",
"andrew.tappan",
"N/A",
"Greenmoustache",
"zounic",
"wfpearl",
"Eldithor",
"Jack B Nimble",
"JaxMax",
"contrite831",
"Jwk0205",
"Starkselle",
"Olive",
"LacesOut!",
"greebles",
"Some Guy Named Barry",
"M Postkasse",
"Gooohokrbe",
"wamekukyouzin",
"OldBones",
"Jacob Hoehler",
"Dogmaster",
"Matt Wenzel",
"Lex Song",
"Cory Paza",
"Gonzalo Andre Allendes Lopez",
"Zach Gonser",
"Serge Bekenkamp",
"Jimmy Ledbetter",
"Philip Hempel",
"dan",
"aai",
"Mouthlessman",
"otaku fra",
"jean jahren",
"MiraiKuriyamaSy",
"Ran C",
"ViperC",
"Penfore",
"Sangheili460",
"MagnaInsomnia",
"Karl P.",
"Akira_HentAI",
"Gordon Cole",
"Adam Taylor",
"AbstractAss",
"andrew.tappan",
"N/A",
"Weird_With_A_Beard",
"The Spawn",
"graysock",
"Greenmoustache",
"fancypants",
"Eldithor",
"Joboshy",
"Digital",
"JaxMax",
"takyamtom",
"Bohemian Corporal",
"Dan",
"Jwk0205",
"Bro Xie",
"yer fey",
"batblue",
"carey6409",
"Olive",
"太郎 ゲーム",
"Some Guy Named Barry",
"jinxedx",
"Cosmosis",
"AELOX",
"Dankin",
"Nicfit23",
"FloPro4Sho",
"wamekukyouzin",
"drum matthieu",
"Dogmaster",
"Matt Wenzel",
"Frank Nitty",
"Christopher Michel",
"Serge Bekenkamp",
"LeoZero",
"Antonio Pontes",
"ApathyJones",
"Julian V",
"Steven Owens",
"nahinahi9",
"Dustin Chen",
"dan",
"Blackfish95",
"Mouthlessman",
"Paul Kroll",
"otaku fra",
"MiraiKuriyamaSy",
"Bas Imagineer",
"yuxz69",
"Adam Taylor",
"Weird_With_A_Beard",
"esthe",
"Pozadine1",
"Qarob",
"AIGooner",
"Luc",
"ProtonPrince",
"DiffDuck",
"Jackthemind",
"fancypants",
"Joboshy",
"Digital",
"takyamtom",
"Bohemian Corporal",
"Dan",
"Bro Xie",
"yer fey",
"batblue",
"carey6409",
"太郎 ゲーム",
"Roslynd",
"jinxedx",
"Neco28",
"Cosmosis",
"David Ortega",
"AELOX",
"Dankin",
"Nicfit23",
"FloPro4Sho",
"Cristian Vazquez",
"drum matthieu",
"Frank Nitty",
"Magic Noob",
"Christopher Michel",
"DougPeterson",
"LeoZero",
"Antonio Pontes",
"ApathyJones",
"Bruce",
"Julian V",
"Steven Owens",
"nahinahi9",
"Kevin John Duck",
"Dustin Chen",
"Blackfish95",
"Paul Kroll",
"Bas Imagineer",
"John Statham",
"yuxz69",
"esthe",
"decoy",
"elu3199",
"Hasturkun",
"Jon Sandman",
@@ -217,49 +228,54 @@
"wundershark",
"mr_dinosaur",
"Tyrswood",
"Ray Wing",
"Ranzitho",
"Gus",
"MJG",
"David LaVallee",
"linnfrey",
"ae",
"Tr4shP4nda",
"IamAyam",
"skaterb949",
"Brian M",
"Josef Lanzl",
"Nerezza",
"sanborondon",
"confiscated Zyra",
"Error_Rule34_Not_found",
"Taylor Funk",
"aezin",
"jcay015",
"Gerald Welly",
"Roslynd",
"Erik Lopez",
"Mateo Curić",
"Tee Gee",
"Geolog",
"tarek helmi",
"Neco28",
"Eris3D",
"Max Marklund",
"David Ortega",
"Cristian Vazquez",
"Magic Noob",
"Pronredn",
"DougPeterson",
"Jamie Ogletree",
"a _",
"Jeff",
"Bruce",
"lh qwe",
"Kevin John Duck",
"James Coleman",
"conner",
"Kevin Christopher",
"Chad Idk",
"dd",
"Princess Bright Eyes",
"Dušan Ryban",
"Felipe dos Santos",
"John Statham",
"Sam",
"sjon kreutz",
"Douglas Gaspar",
"Metryman55",
"AlexDuKaNa",
"George",
"dw",
"decoy",
"Ray Wing",
"Ranzitho",
"Gus",
"地獄の禄",
"MJG",
"David LaVallee",
"ae",
"Tr4shP4nda",
"Gamalonia",
"WRL_SPR",
"capn",
@@ -273,19 +289,24 @@
"몽타주",
"Kland",
"Hailshem",
"kudari",
"Naomi Hale Danchi",
"ken",
"epicgamer0020690",
"Joshua Porrata",
"SuBu",
"RedPIXel",
"Richard",
"奚明 刘",
"Brian M",
"Nerezza",
"sanborondon",
"Andrew",
"Robert Wegemund",
"Littlehuggy",
"준희 김",
"Taylor Funk",
"aezin",
"Brian Buie",
"Thought2Form",
"jcay015",
"Kevin Picco",
"Erik Lopez",
"Mateo Curić",
"Eris3D",
"Sadlip",
"Joey Callahan",
"Tomohiro Baba",
"m",
"Noora",
@@ -293,37 +314,36 @@
"Joshua Gray",
"Mattssn",
"Mikko Hemilä",
"Jamie Ogletree",
"a _",
"James Coleman",
"Jacob McDaniel",
"Temikus",
"Artokun",
"Michael Taylor",
"Derek Baker",
"Martial",
"Michael Anthony Scott",
"Emil Andersson",
"Ouro Boros",
"Chad Idk",
"Atilla Berke Pekduyar",
"Steam Steam",
"CryptoTraderJK",
"Decx _",
"Yuji Kaneko",
"Davaitamin",
"Rops Alot",
"tedcor",
"Sam",
"Fotek Design",
"sjon kreutz",
"Ace Ventura",
"四糸凜音",
"Nihongasuki",
"LarsesFPC",
"MadSpin",
"inbijiburu",
"Nick “Loadstone” D",
"momokai",
"starbugx",
"kudari",
"Naomi Hale Danchi",
"dc7431",
"ken",
"epicgamer0020690",
"Joshua Porrata",
"Crocket",
"keemun",
"SuBu",
"RedPIXel",
"Wind",
"Nexus",
"Ramneek“Guy”Ashok",
@@ -339,72 +359,61 @@
"KitKatM",
"socrasteeze",
"OrganicArtifact",
"ResidentDeviant",
"MudkipMedkitz",
"deanbrian",
"Alex Wortman",
"Cody",
"emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Vir",
"Skyfire83",
"Adam Rinehart",
"Pitpe11",
"TheD1rtyD03",
"gzmzmvp",
"Richard",
"Andrew",
"Robert Wegemund",
"Littlehuggy",
"Gregory Kozhemiak",
"Draven T",
"mrjuan",
"Brian Buie",
"Sadlip",
"Eric Whitney",
"Joey Callahan",
"Aquatic Coffee",
"Ivan Tadic",
"Mike Simone",
"John J Linehan",
"ethanfel",
"Elliot E",
"Morgandel",
"Theerat Jiramate",
"Focuschannel",
"Noah",
"Jacob McDaniel",
"X",
"Sloan Steddy",
"Temikus",
"Artokun",
"Michael Taylor",
"Derek Baker",
"hexxish",
"Anthony Faxlandez",
"battu",
"Michael Anthony Scott",
"Atilla Berke Pekduyar",
"Nathan",
"Decx _",
"NICHOLAS BAXLEY",
"Pat Hen",
"Xeeosat",
"Saya",
"Ed Wang",
"Jordan Shaw",
"g unit",
"Srdb",
"四糸凜音",
"Nihongasuki",
"LarsesFPC",
"JC",
"Prompt Pirate",
"uwutismxd",
"FrxzenSnxw",
"zenobeus",
"Crocket",
"Jackthemind",
"ryoma",
"Stryker",
"ResidentDeviant",
"MudkipMedkitz",
"deanbrian",
"Alex Wortman",
"Cody",
"Ginnie",
"Raku",
"smart.edge5178",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Menard",
"Skyfire83",
"Adam Rinehart",
"Pitpe11",
"TheD1rtyD03",
"moonpetal",
"SomeDude",
"g9p0o",
@@ -415,18 +424,32 @@
"SpringBootisTrash",
"carsten",
"ikok",
"quantenmecha",
"Jason+Nash",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"lrdchs2",
"Duk3+Rand0m",
"Nathen+Choi",
"T",
"cocona",
"ElitaSSJ4",
"David Schenck",
"Wolfe7D1",
"blikkies",
"Chris",
"Time Valentine",
"elleshar666",
"Shock Shockor",
"ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч",
"Matt",
"Goldwaters",
"Kauffy",
"Zude",
"John J Linehan",
"SPJ",
"Kyler",
"Edward Kennedy",
"Justin Blaylock",
@@ -435,21 +458,18 @@
"Vane Holzer",
"psytrax",
"Cyrus Fett",
"hexxish",
"Xenon Xue",
"notedfakes",
"Billy Gladky",
"NICHOLAS BAXLEY",
"Michael Scott",
"Probis",
"Ed Wang",
"Solixer",
"Wes Sims",
"ItsGeneralButtNaked",
"Donor4115",
"g unit",
"Distortik",
"Filippo Ferrari",
"Youguang",
"Saya",
"andrewzpong",
"BossGame",
"lrdchs",
@@ -460,9 +480,18 @@
"Mitchell Robson",
"Whitepinetrader",
"POPPIN",
"Ginnie",
"emadsultan",
"nanana",
"D",
"Dark_Pest",
"Alex",
"Karru",
"ChaChanoKo",
"ghoulars",
"null",
"Beau",
"redcarrot",
"powerbot99",
"Fthehappy",
"g",
"J",
"Alan+Cano",
@@ -471,18 +500,7 @@
"Doug+Rintoul",
"Noor",
"Yorunai",
"quantenmecha",
"Jason+Nash",
"BillyBoy84",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"lrdchs2",
"Duk3+Rand0m",
"Nathen+Choi",
"T",
"cocona",
"Buecyb99",
"Welkor",
"John Martin",
@@ -490,32 +508,41 @@
"JBsuede",
"moranqianlong",
"Kalli Core",
"Time Valentine",
"Михал Михалыч",
"Matt",
"Christian Schäfer",
"りん あめ",
"Joaquin Hierrezuelo",
"Locrospiel",
"Frogmilk",
"SPJ",
"Sean voets",
"Kor",
"Joseph Hanson",
"John Rednoulf",
"Kyron Mahan",
"Bryan Rutkowski",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Ezokewn",
"SendingRavens",
"Xenon Xue",
"Steven",
"JackJohnnyJim",
"TenaciousD",
"Dmitry Ryzhov",
"Khánh Đặng",
"Edward Ten Eyck",
"Michael Docherty",
"Jimmy Borup",
"Paul Hartsuyker",
"Henrique Faiolli",
"elitassj",
"Solixer",
"Pete Pain",
"Jacob Winter",
"Ryan Presley Ng",
"jinksta187",
"RHopkirk",
"Andrew Wilkinson",
"Manu Thetug",
"Karlanx",
"Lyavph",
"Maxim",
"David",
"Meilo",
"operationancut",
@@ -537,6 +564,24 @@
"Scott",
"Muratoraccio",
"D",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"nickname",
"Sildoren",
"Darv",
"Seon+Song",
"2turbo",
"Somebody",
"Balut+Omelette",
"Dmitry+Viznesenskiy",
"tanjin90",
"sternenkrieger",
"eriick",
"Patrick+Bryan",
"Pascalou",
"lighthawke",
"Lev+Lanevskiy",
"low9",
"Winged",
"YassineKhaled",
@@ -547,18 +592,7 @@
"Inkognito",
"G",
"Tan+Huynh",
"D",
"Dark_Pest",
"Alex",
"Jacky+Ho",
"Karru",
"ghoulars",
"ChaChanoKo",
"null",
"Beau",
"redcarrot",
"powerbot99",
"Fthehappy",
"generic404",
"abattoirblues",
"zounik",
@@ -568,50 +602,55 @@
"Bob Barker",
"edk",
"Tú Nguyễn Lý Hoàng",
"shira1011",
"Ben D",
"G",
"Ronan Delevacq",
"Christian Schäfer",
"りん あめ",
"ja s",
"Leslie Andrew Ridings",
"Doug Mason",
"Jeremy Townsend",
"Dave Abraham",
"Joaquin Hierrezuelo",
"Locrospiel",
"Sean voets",
"Owen Gwosdz",
"Jarrid Lee",
"Kor",
"Joseph Hanson",
"John Rednoulf",
"Poophead27 Blyat",
"Spire",
"AZ Party Oasis",
"Boba Smith",
"Devil Lude",
"David Murcko",
"MR.Bear",
"Jack Dole",
"matt",
"somethingtosay8",
"Terminuz",
"ivistorm",
"max blo",
"Sauv",
"Steven",
"CptNeo",
"TenaciousD",
"Dmitry Ryzhov",
"Khánh Đặng",
"Borte",
"Maso",
"Ted Cart",
"Sage Himeros",
"Eric Ketchum",
"Kevin Wallace",
"Jimmy Borup",
"David Spearing",
"ChicRic",
"Tigon",
"BastardSama",
"mercur",
"Pete Pain",
"RHopkirk",
"SkibidiRizzler",
"Tania Nayelli Fernandez",
"Draconach",
"Yavizu3d",
"Maxim",
"Yves Poezevara",
"Teriak47",
"Just me",
"Raf Stahelin",
"Nacho Ferrando",
"Вячеслав Маринин",
"Marcos Tortosa Carmona",
"Dkommander22",
"Cola Matthew",
"OniNoKen",
"Iain Wisely",
@@ -655,6 +694,24 @@
"SelfishMedic",
"adderleighn",
"EnragedAntelope",
"shw",
"Celestial+Kitten",
"bakeliteboy",
"TequiTequi",
"Homero+Banda",
"Nick",
"Jim",
"Monix",
"Trolinka",
"IshouI;_;",
"PredragR",
"Clauzmak",
"Nerick",
"JoL",
"Gold_miner_ego",
"SundayRage",
"YoruHime",
"matter",
"SRCRCOSS",
"imer",
"Akkas+Haque",
@@ -668,25 +725,9 @@
"PoorStudent",
"lucites",
"Alex+Zaw",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"Drizzly",
"Sildoren",
"Darvidous",
"Seon+Song",
"2turbo",
"balut+omelette",
"Nebuleux",
"Dmitry+Viznesenskiy",
"Tanjin90",
"Somebody",
"sternenkrieger",
"eriick",
"Join+Chun",
"Pascalou",
"lighthawke",
"Terraformer",
"GDS+DEV",
"4rt+r3d",
"you+halo9",
@@ -709,64 +750,66 @@
"Nico",
"Maximilian Krischan",
"Banana Joe",
"proto merp",
"_ G3n",
"Donovan Jenkins",
"Hans Meier",
"shira1011",
"sicarius",
"Michael Eid",
"Wolf and Fox Legends",
"beersandbacon",
"Neko Desco",
"Bob barker",
"Ben D",
"Ninja Tom",
"G",
"karim ben brik",
"Vinarus",
"Josh Snyder",
"Michael Zhu",
"Nemisu",
"Seraphy",
"雨の心 落",
"AllTimeNoobie",
"Leslie Andrew Ridings",
"jumpd",
"John C",
"Rim",
"yfx507",
"Room Light",
"Jairus Knudsen",
"Poophead27 Blyat",
"Xan Dionysus",
"Patryk Serious",
"Nathan lee",
"Lyle Liston",
"lylepaul",
"Middo",
"Forbidden Atelier",
"Thomas Sankowski",
"Spire",
"DrB",
"AZ Party Oasis",
"Adictedtohumping",
"Snorklebort",
"vinter",
"Towelie",
"TheFusion",
"matt",
"dsffsdfsdfsdfsdfsdf",
"somethingtosay8",
"Jean-françois SEMA",
"3zS4QNQ4",
"Terminuz",
"Kurt",
"ivistorm",
"Matt M.",
"Ivan Imes",
"J M",
"Slacks",
"Bouya shaka",
"john Greene",
"Faburizu",
"Jack Lawfield",
"jimyjomson",
"Borte",
"JaeHyun Jang",
"Homero Banda",
"Chase Kwon",
"Ted Cart",
"Sage Himeros",
"Bob Ling",
"yyuvuvu",
"Inyoshu",
"Chad Barnes",
"Person Y",
"David Spearing",
"Nomki",
"inusanorthcape",
"James Ming",
"vanditking",
"kripitonga",
@@ -779,7 +822,6 @@
"hannibal",
"Jo+Example",
"BrentBertram",
"inusanorthcape",
"eumelzocker",
"dxjaymz",
"L C",
@@ -787,5 +829,5 @@
"Somebody",
"CK"
],
"totalCount": 784
"totalCount": 826
}

File diff suppressed because one or more lines are too long

View File

@@ -16,10 +16,13 @@
"help": "Hilfe",
"add": "Hinzufügen",
"close": "Schließen",
"menu": "Menü"
"menu": "Menü",
"remove": "Entfernen",
"change": "Ändern"
},
"status": {
"loading": "Wird geladen...",
"cancelling": "Abbrechen...",
"unknown": "Unbekannt",
"date": "Datum",
"version": "Version",
@@ -111,6 +114,7 @@
"replacePreview": "Vorschau ersetzen",
"copyCheckpointName": "Checkpoint-Name kopieren",
"copyEmbeddingName": "Embedding-Name kopieren",
"embeddingNameCopied": "Embedding-Syntax kopiert",
"sendCheckpointToWorkflow": "An ComfyUI senden",
"sendEmbeddingToWorkflow": "An ComfyUI senden"
},
@@ -247,7 +251,18 @@
"toggle": "Theme wechseln",
"switchToLight": "Zu hellem Theme wechseln",
"switchToDark": "Zu dunklem Theme wechseln",
"switchToAuto": "Zu automatischem Theme wechseln"
"switchToAuto": "Zu automatischem Theme wechseln",
"presets": "Theme-Voreinstellungen",
"default": "Standard",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "Modus",
"light": "Hell",
"dark": "Dunkel",
"auto": "Auto"
},
"actions": {
"checkUpdates": "Updates prüfen",
@@ -259,6 +274,9 @@
"civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
"civitaiApiKeyConfigured": "Konfiguriert",
"civitaiApiKeyNotConfigured": "Nicht konfiguriert",
"civitaiApiKeySet": "Einrichten",
"civitaiHost": {
"label": "Civitai-Host",
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
@@ -299,6 +317,7 @@
"downloads": "Downloads",
"videoSettings": "Video-Einstellungen",
"layoutSettings": "Layout-Einstellungen",
"licenseIcons": "Lizenzsymbole",
"misc": "Verschiedenes",
"backup": "Backups",
"folderSettings": "Standard-Roots",
@@ -445,7 +464,9 @@
"modelName": "Modellname",
"fileName": "Dateiname"
},
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll"
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll",
"cardBlurAmount": "Karten-Overlay-Unschärfe",
"cardBlurAmountHelp": "Passen Sie die Unschärfeintensität der Kopf- und Fußzeilen-Overlays auf Modell- und Rezeptkarten an (0 = keine Unschärfe, 20 = maximale Unschärfe)."
},
"folderSettings": {
"activeLibrary": "Aktive Bibliothek",
@@ -577,6 +598,10 @@
"label": "Früher Zugriff Updates ausblenden",
"help": "Nur Early-Access-Updates"
},
"licenseIcons": {
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
"useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design."
},
"misc": {
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen",
@@ -690,6 +715,7 @@
"copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren",
"repairMetadata": "Metadaten der Auswahl reparieren",
"reimportMetadata": "Aus Quelle neu importieren",
"checkUpdates": "Auswahl auf Updates prüfen",
"moveAll": "Alle in Ordner verschieben",
"autoOrganize": "Automatisch organisieren",
@@ -737,6 +763,7 @@
"setContentRating": "Inhaltsbewertung festlegen",
"moveToFolder": "In Ordner verschieben",
"repairMetadata": "Metadaten reparieren",
"reimportMetadata": "Aus Quelle neu importieren",
"excludeModel": "Modell ausschließen",
"restoreModel": "Modell wiederherstellen",
"deleteModel": "Modell löschen",
@@ -864,6 +891,13 @@
"skipped": "Rezept bereits in der neuesten Version, keine Reparatur erforderlich",
"failed": "Rezept-Reparatur fehlgeschlagen: {message}",
"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": {
@@ -942,8 +976,9 @@
"sidebar": {
"modelRoot": "Stammverzeichnis",
"collapseAll": "Alle Ordner einklappen",
"pinSidebar": "Sidebar anheften",
"unpinSidebar": "Sidebar lösen",
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
"showSidebar": "Seitenleiste anzeigen",
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
"switchToListView": "Zur Listenansicht wechseln",
"switchToTreeView": "Zur Baumansicht wechseln",
"recursiveOn": "Unterordner einbeziehen",
@@ -981,6 +1016,18 @@
"storage": "Speicher",
"insights": "Erkenntnisse"
},
"metrics": {
"totalModels": "Modelle gesamt",
"totalStorage": "Speicher gesamt",
"totalGenerations": "Generationen gesamt",
"usageRate": "Nutzungsrate",
"loras": "LoRAs",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"uniqueTags": "Einzigartige Tags",
"unusedModels": "Ungenutzte Modelle",
"avgUsesPerModel": "Ø Nutzungen/Modell"
},
"usage": {
"mostUsedLoras": "Meistgenutzte LoRAs",
"mostUsedCheckpoints": "Meistgenutzte Checkpoints",
@@ -998,13 +1045,77 @@
},
"insights": {
"smartInsights": "Intelligente Erkenntnisse",
"recommendations": "Empfehlungen"
"recommendations": "Empfehlungen",
"noInsights": "Keine Erkenntnisse verfügbar",
"unusedLoras": {
"high": {
"title": "Hohe Anzahl ungenutzter LoRAs",
"description": "{percent}% Ihrer LoRAs ({count}/{total}) wurden noch nie verwendet.",
"suggestion": "Erwägen Sie, ungenutzte Modelle zu organisieren oder zu archivieren, um Speicherplatz freizugeben."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Ungenutzte Checkpoints erkannt",
"description": "{percent}% Ihrer Checkpoints ({count}/{total}) wurden noch nie verwendet.",
"suggestion": "Überprüfen Sie nicht mehr benötigte Checkpoints und erwägen Sie deren Entfernung."
}
},
"unusedEmbeddings": {
"high": {
"title": "Hohe Anzahl ungenutzter Embeddings",
"description": "{percent}% Ihrer Embeddings ({count}/{total}) wurden noch nie verwendet.",
"suggestion": "Organisieren oder archivieren Sie ungenutzte Embeddings, um Ihre Sammlung zu optimieren."
}
},
"collection": {
"large": {
"title": "Große Sammlung erkannt",
"description": "Ihre Modellsammlung verwendet {size} Speicher.",
"suggestion": "Erwägen Sie externe Speicher- oder Cloud-Lösungen für eine bessere Organisation."
}
},
"activity": {
"active": {
"title": "Aktiver Benutzer",
"description": "Sie haben {count} Generationen abgeschlossen!",
"suggestion": "Entdecken und erstellen Sie weiterhin großartige Inhalte mit Ihren Modellen."
}
}
},
"charts": {
"collectionOverview": "Sammlungsübersicht",
"baseModelDistribution": "Basis-Modell-Verteilung",
"usageTrends": "Nutzungstrends (Letzte 30 Tage)",
"usageDistribution": "Nutzungsverteilung"
"usageDistribution": "Nutzungsverteilung",
"date": "Datum",
"usageCount": "Nutzungsanzahl",
"fileSizeBytes": "Dateigröße (Bytes)",
"models": "Modelle",
"loraUsage": "LoRA-Nutzung",
"checkpointUsage": "Checkpoint-Nutzung",
"embeddingUsage": "Embedding-Nutzung"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusionsmodell",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Lädt...",
"noModels": "Keine Modelle gefunden",
"errorLoading": "Fehler beim Laden der Daten",
"noStorageData": "Keine Speicherdaten verfügbar",
"rootFolder": "Root",
"chartLibraryMissing": "Diagramm benötigt Chart.js-Bibliothek"
},
"tooltips": {
"tagCount": "{tag}: {count} Modelle",
"chartUsage": "{name}: {size}, {count} Nutzungen",
"chartPercentage": "{label}: {value} ({pct}%)"
}
},
"modals": {
@@ -1014,9 +1125,9 @@
"download": {
"title": "Modell von URL herunterladen",
"titleWithType": "{type} von URL herunterladen",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Geben Sie eine CivitAI- oder CivArchive-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
"locationPreview": "Download-Speicherort Vorschau",
"useDefaultPath": "Standardpfad verwenden",
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
@@ -1225,7 +1336,9 @@
},
"notes": {
"saved": "Notizen erfolgreich gespeichert",
"saveFailed": "Fehler beim Speichern der Notizen"
"saveFailed": "Fehler beim Speichern der Notizen",
"showMore": "Mehr anzeigen",
"showLess": "Weniger anzeigen"
},
"usageTips": {
"addPresetParameter": "Voreingestellten Parameter hinzufügen...",
@@ -1378,6 +1491,21 @@
"versionDeleted": "Version gelöscht"
}
}
},
"metadataFetchSummary": {
"title": "Metadaten abrufen — Zusammenfassung",
"statSuccess": "Erfolgreich",
"statFailed": "Fehlgeschlagen",
"statSkipped": "Übersprungen",
"statTotal": "Gesamt geprüft",
"statDuration": "Dauer",
"successMessage": "Alle {count} {type}s erfolgreich aktualisiert!",
"failedItems": "Fehlgeschlagene Elemente ({count})",
"close": "Schließen",
"copyReport": "Bericht kopieren",
"downloadCsv": "CSV herunterladen",
"columnModelName": "Modellname",
"columnError": "Fehler"
}
},
"modelTags": {
@@ -1391,15 +1519,6 @@
"duplicate": "Dieser Tag existiert bereits"
}
},
"keyboard": {
"navigation": "Tastatur-Navigation:",
"shortcuts": {
"pageUp": "Eine Seite nach oben scrollen",
"pageDown": "Eine Seite nach unten scrollen",
"home": "Zum Anfang springen",
"end": "Zum Ende springen"
}
},
"initialization": {
"title": "Initialisierung",
"message": "Ihr Arbeitsbereich wird vorbereitet...",
@@ -1487,11 +1606,14 @@
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
"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": {
"recipe": "Rezept",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "Ersetzen",
"append": "Anhängen",
"selectTargetNode": "Zielknoten auswählen",
@@ -1713,6 +1835,10 @@
"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",
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
},
@@ -1930,7 +2056,9 @@
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "In die Zwischenablage kopiert",
"downloadStarted": "Download gestartet"
}
},
"doctor": {

View File

@@ -16,10 +16,13 @@
"help": "Help",
"add": "Add",
"close": "Close",
"menu": "Menu"
"menu": "Menu",
"remove": "Remove",
"change": "Change"
},
"status": {
"loading": "Loading...",
"cancelling": "Cancelling...",
"unknown": "Unknown",
"date": "Date",
"version": "Version",
@@ -111,6 +114,7 @@
"replacePreview": "Replace Preview",
"copyCheckpointName": "Copy checkpoint name",
"copyEmbeddingName": "Copy embedding name",
"embeddingNameCopied": "Embedding syntax copied",
"sendCheckpointToWorkflow": "Send to ComfyUI",
"sendEmbeddingToWorkflow": "Send to ComfyUI"
},
@@ -247,7 +251,18 @@
"toggle": "Toggle theme",
"switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark theme",
"switchToAuto": "Switch to auto theme"
"switchToAuto": "Switch to auto theme",
"presets": "Theme Presets",
"default": "Default",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "Mode",
"light": "Light",
"dark": "Dark",
"auto": "Auto"
},
"actions": {
"checkUpdates": "Check Updates",
@@ -259,6 +274,9 @@
"civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
"civitaiApiKeyConfigured": "Configured",
"civitaiApiKeyNotConfigured": "Not configured",
"civitaiApiKeySet": "Set up",
"civitaiHost": {
"label": "Civitai host",
"help": "Choose which Civitai site opens when using View on Civitai links.",
@@ -299,6 +317,7 @@
"downloads": "Downloads",
"videoSettings": "Video Settings",
"layoutSettings": "Layout Settings",
"licenseIcons": "License Icons",
"misc": "Miscellaneous",
"backup": "Backups",
"folderSettings": "Default Roots",
@@ -445,7 +464,9 @@
"modelName": "Model Name",
"fileName": "File Name"
},
"modelNameDisplayHelp": "Choose what to display in the model card footer"
"modelNameDisplayHelp": "Choose what to display in the model card footer",
"cardBlurAmount": "Card Overlay Blur",
"cardBlurAmountHelp": "Adjust the blur intensity of the header and footer overlays on model and recipe cards (0 = no blur, 20 = maximum blur)."
},
"folderSettings": {
"activeLibrary": "Active Library",
@@ -577,6 +598,10 @@
"label": "Hide Early Access Updates",
"help": "When enabled, models with only early access updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "Use updated license icons",
"useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design."
},
"misc": {
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard",
@@ -690,6 +715,7 @@
"copyAll": "Copy Selected Syntax",
"refreshAll": "Refresh Selected Metadata",
"repairMetadata": "Repair Metadata for Selected",
"reimportMetadata": "Re-import from Source",
"checkUpdates": "Check Updates for Selected",
"moveAll": "Move Selected to Folder",
"autoOrganize": "Auto-Organize Selected",
@@ -737,6 +763,7 @@
"setContentRating": "Set Content Rating",
"moveToFolder": "Move to Folder",
"repairMetadata": "Repair metadata",
"reimportMetadata": "Re-import from Source",
"excludeModel": "Exclude Model",
"restoreModel": "Restore Model",
"deleteModel": "Delete Model",
@@ -864,6 +891,13 @@
"skipped": "Recipe already at latest version, no repair needed",
"failed": "Failed to repair recipe: {message}",
"missingId": "Cannot repair recipe: Missing recipe ID"
},
"reimport": {
"starting": "Re-importing recipe from source...",
"success": "Recipe re-imported successfully",
"noSourceUrl": "Recipe has no source URL, cannot re-import",
"failed": "Failed to re-import recipe: {message}",
"missingId": "Cannot re-import recipe: Missing recipe ID"
}
},
"batchImport": {
@@ -942,8 +976,9 @@
"sidebar": {
"modelRoot": "Root",
"collapseAll": "Collapse All Folders",
"pinSidebar": "Pin 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",
"switchToTreeView": "Switch to Tree View",
"recursiveOn": "Include subfolders",
@@ -981,6 +1016,18 @@
"storage": "Storage",
"insights": "Insights"
},
"metrics": {
"totalModels": "Total Models",
"totalStorage": "Total Storage",
"totalGenerations": "Total Generations",
"usageRate": "Usage Rate",
"loras": "LoRAs",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"uniqueTags": "Unique Tags",
"unusedModels": "Unused Models",
"avgUsesPerModel": "Avg. Uses/Model"
},
"usage": {
"mostUsedLoras": "Most Used LoRAs",
"mostUsedCheckpoints": "Most Used Checkpoints",
@@ -998,13 +1045,77 @@
},
"insights": {
"smartInsights": "Smart Insights",
"recommendations": "Recommendations"
"recommendations": "Recommendations",
"noInsights": "No insights available",
"unusedLoras": {
"high": {
"title": "High Number of Unused LoRAs",
"description": "{percent}% of your LoRAs ({count}/{total}) have never been used.",
"suggestion": "Consider organizing or archiving unused models to free up storage space."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Unused Checkpoints Detected",
"description": "{percent}% of your checkpoints ({count}/{total}) have never been used.",
"suggestion": "Review and consider removing checkpoints you no longer need."
}
},
"unusedEmbeddings": {
"high": {
"title": "High Number of Unused Embeddings",
"description": "{percent}% of your embeddings ({count}/{total}) have never been used.",
"suggestion": "Consider organizing or archiving unused embeddings to optimize your collection."
}
},
"collection": {
"large": {
"title": "Large Collection Detected",
"description": "Your model collection is using {size} of storage.",
"suggestion": "Consider using external storage or cloud solutions for better organization."
}
},
"activity": {
"active": {
"title": "Active User",
"description": "You've completed {count} generations so far!",
"suggestion": "Keep exploring and creating amazing content with your models."
}
}
},
"charts": {
"collectionOverview": "Collection Overview",
"baseModelDistribution": "Base Model Distribution",
"usageTrends": "Usage Trends (Last 30 Days)",
"usageDistribution": "Usage Distribution"
"usageDistribution": "Usage Distribution",
"date": "Date",
"usageCount": "Usage Count",
"fileSizeBytes": "File Size (bytes)",
"models": "Models",
"loraUsage": "LoRA Usage",
"checkpointUsage": "Checkpoint Usage",
"embeddingUsage": "Embedding Usage"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Loading...",
"noModels": "No models found",
"errorLoading": "Error loading data",
"noStorageData": "No storage data available",
"rootFolder": "Root",
"chartLibraryMissing": "Chart requires Chart.js library"
},
"tooltips": {
"tagCount": "{tag}: {count} models",
"chartUsage": "{name}: {size}, {count} uses",
"chartPercentage": "{label}: {value} ({pct}%)"
}
},
"modals": {
@@ -1014,9 +1125,9 @@
"download": {
"title": "Download Model from URL",
"titleWithType": "Download {type} from URL",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL:",
"civitaiUrl": "Civitai URL(s):",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Enter one CivitAI or CivArchive URL per line. Supports multiple URLs for batch download.",
"locationPreview": "Download Location Preview",
"useDefaultPath": "Use Default Path",
"useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates",
@@ -1225,7 +1336,9 @@
},
"notes": {
"saved": "Notes saved successfully",
"saveFailed": "Failed to save notes"
"saveFailed": "Failed to save notes",
"showMore": "Show more",
"showLess": "Show less"
},
"usageTips": {
"addPresetParameter": "Add preset parameter...",
@@ -1378,6 +1491,21 @@
"versionDeleted": "Version deleted"
}
}
},
"metadataFetchSummary": {
"title": "Metadata Fetch Summary",
"statSuccess": "Success",
"statFailed": "Failed",
"statSkipped": "Skipped",
"statTotal": "Total Scanned",
"statDuration": "Duration",
"successMessage": "All {count} {type}s updated successfully!",
"failedItems": "Failed Items ({count})",
"close": "Close",
"copyReport": "Copy Report",
"downloadCsv": "Download CSV",
"columnModelName": "Model Name",
"columnError": "Error"
}
},
"modelTags": {
@@ -1391,15 +1519,6 @@
"duplicate": "This tag already exists"
}
},
"keyboard": {
"navigation": "Keyboard Navigation:",
"shortcuts": {
"pageUp": "Scroll up one page",
"pageDown": "Scroll down one page",
"home": "Jump to top",
"end": "Jump to bottom"
}
},
"initialization": {
"title": "Initializing",
"message": "Preparing your workspace...",
@@ -1487,11 +1606,14 @@
"noMatchingNodes": "No compatible nodes available in the current workflow",
"noTargetNodeSelected": "No target node selected",
"modelUpdated": "Model updated in workflow",
"modelFailed": "Failed to update model node"
"modelFailed": "Failed to update model node",
"embeddingAdded": "Embedding added to workflow",
"embeddingFailed": "Failed to add embedding"
},
"nodeSelector": {
"recipe": "Recipe",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "Replace",
"append": "Append",
"selectTargetNode": "Select target node",
@@ -1713,6 +1835,10 @@
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
"repairBulkFailed": "Failed to repair selected recipes: {message}",
"reimporting": "Re-importing recipe from source...",
"reimportSuccess": "Recipe re-imported successfully",
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
"reimportBulkFailed": "Failed to re-import some recipes",
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
},
@@ -1930,7 +2056,9 @@
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
"exampleImagesDownloadSuccess": "Successfully downloaded example images!",
"exampleImagesDownloadFailed": "Failed to download example images: {message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Copied to clipboard",
"downloadStarted": "Download started"
}
},
"doctor": {

View File

@@ -16,10 +16,13 @@
"help": "Ayuda",
"add": "Añadir",
"close": "Cerrar",
"menu": "Menú"
"menu": "Menú",
"remove": "Eliminar",
"change": "Cambiar"
},
"status": {
"loading": "Cargando...",
"cancelling": "Cancelando...",
"unknown": "Desconocido",
"date": "Fecha",
"version": "Versión",
@@ -111,6 +114,7 @@
"replacePreview": "Reemplazar vista previa",
"copyCheckpointName": "Copiar nombre del checkpoint",
"copyEmbeddingName": "Copiar nombre del embedding",
"embeddingNameCopied": "Sintaxis de embedding copiada",
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
},
@@ -247,7 +251,18 @@
"toggle": "Cambiar tema",
"switchToLight": "Cambiar a tema claro",
"switchToDark": "Cambiar a tema oscuro",
"switchToAuto": "Cambiar a tema automático"
"switchToAuto": "Cambiar a tema automático",
"presets": "Preajustes de tema",
"default": "Predeterminado",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "Modo",
"light": "Claro",
"dark": "Oscuro",
"auto": "Auto"
},
"actions": {
"checkUpdates": "Comprobar actualizaciones",
@@ -259,6 +274,9 @@
"civitaiApiKey": "Clave API de Civitai",
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
"civitaiApiKeyConfigured": "Configurado",
"civitaiApiKeyNotConfigured": "No configurado",
"civitaiApiKeySet": "Configurar",
"civitaiHost": {
"label": "Host de Civitai",
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
@@ -299,6 +317,7 @@
"downloads": "Descargas",
"videoSettings": "Configuración de video",
"layoutSettings": "Configuración de diseño",
"licenseIcons": "Iconos de licencia",
"misc": "Varios",
"backup": "Copias de seguridad",
"folderSettings": "Raíces predeterminadas",
@@ -445,7 +464,9 @@
"modelName": "Nombre del modelo",
"fileName": "Nombre del archivo"
},
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo"
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo",
"cardBlurAmount": "Desenfoque de superposición de tarjetas",
"cardBlurAmountHelp": "Ajuste la intensidad de desenfoque de las superposiciones del encabezado y pie de página en las tarjetas de modelos y recetas (0 = sin desenfoque, 20 = desenfoque máximo)."
},
"folderSettings": {
"activeLibrary": "Biblioteca activa",
@@ -577,6 +598,10 @@
"label": "Ocultar actualizaciones de acceso temprano",
"help": "Solo actualizaciones de acceso temprano"
},
"licenseIcons": {
"useNewStyle": "Usar iconos de licencia actualizados",
"useNewStyleHelp": "Mostrar permisos de licencia con indicadores de color (nuevo estilo) o solo iconos de restricción (estilo clásico). Refleja el diseño actual de CivitAI."
},
"misc": {
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles",
@@ -690,6 +715,7 @@
"copyAll": "Copiar toda la sintaxis",
"refreshAll": "Actualizar todos los metadatos",
"repairMetadata": "Reparar metadatos de la selección",
"reimportMetadata": "Reimportar desde origen",
"checkUpdates": "Comprobar actualizaciones para la selección",
"moveAll": "Mover todos a carpeta",
"autoOrganize": "Auto-organizar seleccionados",
@@ -737,6 +763,7 @@
"setContentRating": "Establecer clasificación de contenido",
"moveToFolder": "Mover a carpeta",
"repairMetadata": "Reparar metadatos",
"reimportMetadata": "Reimportar desde origen",
"excludeModel": "Excluir modelo",
"restoreModel": "Restaurar modelo",
"deleteModel": "Eliminar modelo",
@@ -864,6 +891,13 @@
"skipped": "La receta ya está en la última versión, no se necesita reparación",
"failed": "Error al reparar la receta: {message}",
"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": {
@@ -942,8 +976,9 @@
"sidebar": {
"modelRoot": "Raíz",
"collapseAll": "Colapsar todas las carpetas",
"pinSidebar": "Fijar 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",
"switchToTreeView": "Cambiar a vista de árbol",
"recursiveOn": "Incluir subcarpetas",
@@ -981,6 +1016,18 @@
"storage": "Almacenamiento",
"insights": "Perspectivas"
},
"metrics": {
"totalModels": "Total de modelos",
"totalStorage": "Almacenamiento total",
"totalGenerations": "Generaciones totales",
"usageRate": "Tasa de uso",
"loras": "LoRAs",
"checkpoints": "Puntos de control",
"embeddings": "Embeddings",
"uniqueTags": "Etiquetas únicas",
"unusedModels": "Modelos no usados",
"avgUsesPerModel": "Prom. usos/modelo"
},
"usage": {
"mostUsedLoras": "LoRAs más utilizados",
"mostUsedCheckpoints": "Checkpoints más utilizados",
@@ -998,13 +1045,77 @@
},
"insights": {
"smartInsights": "Perspectivas inteligentes",
"recommendations": "Recomendaciones"
"recommendations": "Recomendaciones",
"noInsights": "No hay información disponible",
"unusedLoras": {
"high": {
"title": "Alta cantidad de LoRAs no utilizadas",
"description": "El {percent}% de tus LoRAs ({count}/{total}) nunca se han utilizado.",
"suggestion": "Considera organizar o archivar modelos no utilizados para liberar espacio."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Puntos de control no utilizados detectados",
"description": "El {percent}% de tus puntos de control ({count}/{total}) nunca se han utilizado.",
"suggestion": "Revisa y considera eliminar los puntos de control que ya no necesites."
}
},
"unusedEmbeddings": {
"high": {
"title": "Alta cantidad de Embeddings no utilizados",
"description": "El {percent}% de tus embeddings ({count}/{total}) nunca se han utilizado.",
"suggestion": "Considera organizar o archivar embeddings no utilizados para optimizar tu colección."
}
},
"collection": {
"large": {
"title": "Colección grande detectada",
"description": "Tu colección de modelos está usando {size} de almacenamiento.",
"suggestion": "Considera usar almacenamiento externo o soluciones en la nube para una mejor organización."
}
},
"activity": {
"active": {
"title": "Usuario activo",
"description": "¡Has completado {count} generaciones hasta ahora!",
"suggestion": "Sigue explorando y creando contenido increíble con tus modelos."
}
}
},
"charts": {
"collectionOverview": "Resumen de colección",
"baseModelDistribution": "Distribución de modelo base",
"usageTrends": "Tendencias de uso (Últimos 30 días)",
"usageDistribution": "Distribución de uso"
"usageDistribution": "Distribución de uso",
"date": "Fecha",
"usageCount": "Conteo de uso",
"fileSizeBytes": "Tamaño del archivo (bytes)",
"models": "Modelos",
"loraUsage": "Uso de LoRA",
"checkpointUsage": "Uso de Checkpoint",
"embeddingUsage": "Uso de Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Punto de control",
"diffusion_model": "Modelo de difusión",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Cargando...",
"noModels": "No se encontraron modelos",
"errorLoading": "Error al cargar datos",
"noStorageData": "No hay datos de almacenamiento disponibles",
"rootFolder": "Raíz",
"chartLibraryMissing": "El gráfico requiere la librería Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} modelos",
"chartUsage": "{name}: {size}, {count} usos",
"chartPercentage": "{label}: {value} ({pct}%)"
}
},
"modals": {
@@ -1014,9 +1125,9 @@
"download": {
"title": "Descargar modelo desde URL",
"titleWithType": "Descargar {type} desde URL",
"url": "URL de Civitai",
"civitaiUrl": "URL de Civitai:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Ingrese una URL de CivitAI o CivArchive por línea. Admite múltiples URLs para descarga por lotes.",
"locationPreview": "Vista previa de ubicación de descarga",
"useDefaultPath": "Usar ruta predeterminada",
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
@@ -1225,7 +1336,9 @@
},
"notes": {
"saved": "Notas guardadas exitosamente",
"saveFailed": "Error al guardar notas"
"saveFailed": "Error al guardar notas",
"showMore": "Mostrar más",
"showLess": "Mostrar menos"
},
"usageTips": {
"addPresetParameter": "Añadir parámetro preestablecido...",
@@ -1378,6 +1491,21 @@
"versionDeleted": "Versión eliminada"
}
}
},
"metadataFetchSummary": {
"title": "Resumen de obtención de metadatos",
"statSuccess": "Éxito",
"statFailed": "Fallido",
"statSkipped": "Omitido",
"statTotal": "Total escaneado",
"statDuration": "Duración",
"successMessage": "¡Todos los {count} {type}s actualizados correctamente!",
"failedItems": "Elementos fallidos ({count})",
"close": "Cerrar",
"copyReport": "Copiar informe",
"downloadCsv": "Descargar CSV",
"columnModelName": "Nombre del modelo",
"columnError": "Error"
}
},
"modelTags": {
@@ -1391,15 +1519,6 @@
"duplicate": "Esta etiqueta ya existe"
}
},
"keyboard": {
"navigation": "Navegación por teclado:",
"shortcuts": {
"pageUp": "Desplazar hacia arriba una página",
"pageDown": "Desplazar hacia abajo una página",
"home": "Saltar al inicio",
"end": "Saltar al final"
}
},
"initialization": {
"title": "Inicializando",
"message": "Preparando tu espacio de trabajo...",
@@ -1487,11 +1606,14 @@
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
"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": {
"recipe": "Receta",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "Reemplazar",
"append": "Añadir",
"selectTargetNode": "Seleccionar nodo de destino",
@@ -1713,6 +1835,10 @@
"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",
"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."
},
@@ -1930,7 +2056,9 @@
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Copiado al portapapeles",
"downloadStarted": "Descarga iniciada"
}
},
"doctor": {

View File

@@ -16,10 +16,13 @@
"help": "Aide",
"add": "Ajouter",
"close": "Fermer",
"menu": "Menu"
"menu": "Menu",
"remove": "Supprimer",
"change": "Modifier"
},
"status": {
"loading": "Chargement...",
"cancelling": "Annulation...",
"unknown": "Inconnu",
"date": "Date",
"version": "Version",
@@ -111,6 +114,7 @@
"replacePreview": "Remplacer l'aperçu",
"copyCheckpointName": "Copier le nom du checkpoint",
"copyEmbeddingName": "Copier le nom de l'embedding",
"embeddingNameCopied": "Syntaxe dembedding copiée",
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
},
@@ -247,7 +251,18 @@
"toggle": "Basculer le thème",
"switchToLight": "Passer au thème clair",
"switchToDark": "Passer au thème sombre",
"switchToAuto": "Passer au thème automatique"
"switchToAuto": "Passer au thème automatique",
"presets": "Préréglages de thème",
"default": "Par défaut",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "Mode",
"light": "Clair",
"dark": "Sombre",
"auto": "Auto"
},
"actions": {
"checkUpdates": "Vérifier les mises à jour",
@@ -259,6 +274,9 @@
"civitaiApiKey": "Clé API Civitai",
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
"civitaiApiKeyConfigured": "Configuré",
"civitaiApiKeyNotConfigured": "Non configuré",
"civitaiApiKeySet": "Configurer",
"civitaiHost": {
"label": "Hôte Civitai",
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
@@ -299,6 +317,7 @@
"downloads": "Téléchargements",
"videoSettings": "Paramètres vidéo",
"layoutSettings": "Paramètres d'affichage",
"licenseIcons": "Icônes de licence",
"misc": "Divers",
"backup": "Sauvegardes",
"folderSettings": "Racines par défaut",
@@ -445,7 +464,9 @@
"modelName": "Nom du modèle",
"fileName": "Nom du fichier"
},
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle"
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle",
"cardBlurAmount": "Flou de superposition des cartes",
"cardBlurAmountHelp": "Ajustez l'intensité du flou des superpositions d'en-tête et de pied de page sur les cartes de modèles et de recettes (0 = aucun flou, 20 = flou maximal)."
},
"folderSettings": {
"activeLibrary": "Bibliothèque active",
@@ -577,6 +598,10 @@
"label": "Masquer les mises à jour en accès anticipé",
"help": "Seulement les mises à jour en accès anticipé"
},
"licenseIcons": {
"useNewStyle": "Utiliser les icônes de licence mises à jour",
"useNewStyleHelp": "Afficher les permissions de licence avec des indicateurs colorés (nouveau style) ou des icônes de restriction uniquement (style classique). Reprend le design actuel de CivitAI."
},
"misc": {
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers",
@@ -690,6 +715,7 @@
"copyAll": "Copier toute la syntaxe",
"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",
"moveAll": "Déplacer tout vers un dossier",
"autoOrganize": "Auto-organiser la sélection",
@@ -737,6 +763,7 @@
"setContentRating": "Définir la classification du contenu",
"moveToFolder": "Déplacer vers un dossier",
"repairMetadata": "Réparer les métadonnées",
"reimportMetadata": "Ré-importer depuis la source",
"excludeModel": "Exclure le modèle",
"restoreModel": "Restaurer le modèle",
"deleteModel": "Supprimer le modèle",
@@ -864,6 +891,13 @@
"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}",
"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": {
@@ -942,8 +976,9 @@
"sidebar": {
"modelRoot": "Racine",
"collapseAll": "Réduire tous les dossiers",
"pinSidebar": "É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",
"switchToTreeView": "Passer en vue arborescence",
"recursiveOn": "Inclure les sous-dossiers",
@@ -981,6 +1016,18 @@
"storage": "Stockage",
"insights": "Aperçus"
},
"metrics": {
"totalModels": "Total des modèles",
"totalStorage": "Stockage total",
"totalGenerations": "Générations totales",
"usageRate": "Taux d'utilisation",
"loras": "LoRAs",
"checkpoints": "Points de contrôle",
"embeddings": "Embeddings",
"uniqueTags": "Tags uniques",
"unusedModels": "Modèles inutilisés",
"avgUsesPerModel": "Moy. utilisations/modèle"
},
"usage": {
"mostUsedLoras": "LoRAs les plus utilisés",
"mostUsedCheckpoints": "Checkpoints les plus utilisés",
@@ -998,13 +1045,77 @@
},
"insights": {
"smartInsights": "Aperçus intelligents",
"recommendations": "Recommandations"
"recommendations": "Recommandations",
"noInsights": "Aucun aperçu disponible",
"unusedLoras": {
"high": {
"title": "Nombre élevé de LoRAs inutilisées",
"description": "{percent}% de vos LoRAs ({count}/{total}) n'ont jamais été utilisées.",
"suggestion": "Envisagez d'organiser ou d'archiver les modèles inutilisés pour libérer de l'espace."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Points de contrôle inutilisés détectés",
"description": "{percent}% de vos points de contrôle ({count}/{total}) n'ont jamais été utilisés.",
"suggestion": "Examinez et envisagez de supprimer les points de contrôle dont vous n'avez plus besoin."
}
},
"unusedEmbeddings": {
"high": {
"title": "Nombre élevé d'Embeddings inutilisées",
"description": "{percent}% de vos embeddings ({count}/{total}) n'ont jamais été utilisées.",
"suggestion": "Envisagez d'organiser ou d'archiver les embeddings inutilisées pour optimiser votre collection."
}
},
"collection": {
"large": {
"title": "Grande collection détectée",
"description": "Votre collection de modèles utilise {size} de stockage.",
"suggestion": "Envisagez d'utiliser un stockage externe ou des solutions cloud pour une meilleure organisation."
}
},
"activity": {
"active": {
"title": "Utilisateur actif",
"description": "Vous avez effectué {count} générations jusqu'à présent !",
"suggestion": "Continuez à explorer et à créer du contenu formidable avec vos modèles."
}
}
},
"charts": {
"collectionOverview": "Aperçu de la collection",
"baseModelDistribution": "Distribution des modèles de base",
"usageTrends": "Tendances d'utilisation (30 derniers jours)",
"usageDistribution": "Distribution de l'utilisation"
"usageDistribution": "Distribution de l'utilisation",
"date": "Date",
"usageCount": "Nombre d'utilisations",
"fileSizeBytes": "Taille du fichier (octets)",
"models": "Modèles",
"loraUsage": "Utilisation LoRA",
"checkpointUsage": "Utilisation Checkpoint",
"embeddingUsage": "Utilisation Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Point de contrôle",
"diffusion_model": "Modèle de diffusion",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Chargement...",
"noModels": "Aucun modèle trouvé",
"errorLoading": "Erreur de chargement des données",
"noStorageData": "Aucune donnée de stockage disponible",
"rootFolder": "Racine",
"chartLibraryMissing": "Le graphique nécessite la bibliothèque Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} modèles",
"chartUsage": "{name}: {size}, {count} utilisations",
"chartPercentage": "{label}: {value} ({pct}%)"
}
},
"modals": {
@@ -1014,9 +1125,9 @@
"download": {
"title": "Télécharger un modèle depuis une URL",
"titleWithType": "Télécharger {type} depuis une URL",
"url": "URL Civitai",
"civitaiUrl": "URL Civitai :",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Entrez une URL CivitAI ou CivArchive par ligne. Prend en charge plusieurs URLs pour le téléchargement par lot.",
"locationPreview": "Aperçu de l'emplacement de téléchargement",
"useDefaultPath": "Utiliser le chemin par défaut",
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
@@ -1225,7 +1336,9 @@
},
"notes": {
"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": {
"addPresetParameter": "Ajouter un paramètre prédéfini...",
@@ -1378,6 +1491,21 @@
"versionDeleted": "Version supprimée"
}
}
},
"metadataFetchSummary": {
"title": "Récapitulatif de la récupération des métadonnées",
"statSuccess": "Réussi",
"statFailed": "Échoué",
"statSkipped": "Ignoré",
"statTotal": "Total scanné",
"statDuration": "Durée",
"successMessage": "Tous les {count} {type}s mis à jour avec succès !",
"failedItems": "Éléments échoués ({count})",
"close": "Fermer",
"copyReport": "Copier le rapport",
"downloadCsv": "Télécharger CSV",
"columnModelName": "Nom du modèle",
"columnError": "Erreur"
}
},
"modelTags": {
@@ -1391,15 +1519,6 @@
"duplicate": "Ce tag existe déjà"
}
},
"keyboard": {
"navigation": "Navigation au clavier :",
"shortcuts": {
"pageUp": "Défiler d'une page vers le haut",
"pageDown": "Défiler d'une page vers le bas",
"home": "Aller en haut",
"end": "Aller en bas"
}
},
"initialization": {
"title": "Initialisation",
"message": "Préparation de votre espace de travail...",
@@ -1487,11 +1606,14 @@
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
"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": {
"recipe": "Recipe",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "Remplacer",
"append": "Ajouter",
"selectTargetNode": "Sélectionner le nœud cible",
@@ -1713,6 +1835,10 @@
"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",
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
},
@@ -1930,7 +2056,9 @@
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Copié dans le presse-papiers",
"downloadStarted": "Téléchargement démarré"
}
},
"doctor": {

View File

@@ -16,10 +16,13 @@
"help": "עזרה",
"add": "הוספה",
"close": "סגור",
"menu": "תפריט"
"menu": "תפריט",
"remove": "הסר",
"change": "שנה"
},
"status": {
"loading": "טוען...",
"cancelling": "מבטל...",
"unknown": "לא ידוע",
"date": "תאריך",
"version": "גרסה",
@@ -111,6 +114,7 @@
"replacePreview": "החלף תצוגה מקדימה",
"copyCheckpointName": "העתק שם Checkpoint",
"copyEmbeddingName": "העתק שם Embedding",
"embeddingNameCopied": "תחביר Embedding הועתק",
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
},
@@ -247,7 +251,18 @@
"toggle": "החלף ערכת נושא",
"switchToLight": "עבור לערכת נושא בהירה",
"switchToDark": "עבור לערכת נושא כהה",
"switchToAuto": "עבור לערכת נושא אוטומטית"
"switchToAuto": "עבור לערכת נושא אוטומטית",
"presets": "ערכות נושא מוגדרות",
"default": "ברירת מחדל",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "מצב",
"light": "בהיר",
"dark": "כהה",
"auto": "אוטומטי"
},
"actions": {
"checkUpdates": "בדוק עדכונים",
@@ -259,6 +274,9 @@
"civitaiApiKey": "מפתח API של Civitai",
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
"civitaiApiKeyConfigured": "מוגדר",
"civitaiApiKeyNotConfigured": "לא מוגדר",
"civitaiApiKeySet": "הגדר",
"civitaiHost": {
"label": "מארח Civitai",
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
@@ -299,6 +317,7 @@
"downloads": "הורדות",
"videoSettings": "הגדרות וידאו",
"layoutSettings": "הגדרות פריסה",
"licenseIcons": "סמלי רישיון",
"misc": "שונות",
"backup": "גיבויים",
"folderSettings": "תיקיות ברירת מחדל",
@@ -445,7 +464,9 @@
"modelName": "שם מודל",
"fileName": "שם קובץ"
},
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל"
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל",
"cardBlurAmount": "עוצמת טשטוש שכבת-על בכרטיס",
"cardBlurAmountHelp": "כוונן את עוצמת הטשטוש של שכבת-העל בכותרת ובכותרות תחתונה בכרטיסי מודל ומתכונים (0 = ללא טשטוש, 20 = טשטוש מקסימלי)."
},
"folderSettings": {
"activeLibrary": "ספרייה פעילה",
@@ -577,6 +598,10 @@
"label": "הסתר עדכוני גישה מוקדמת",
"help": "רק עדכוני גישה מוקדמת"
},
"licenseIcons": {
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
},
"misc": {
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח",
@@ -690,6 +715,7 @@
"copyAll": "העתק את כל התחבירים",
"refreshAll": "רענן את כל המטא-דאטה",
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
"reimportMetadata": "ייבא מחדש ממקור",
"checkUpdates": "בדוק עדכונים לבחירה",
"moveAll": "העבר הכל לתיקייה",
"autoOrganize": "ארגן אוטומטית נבחרים",
@@ -737,6 +763,7 @@
"setContentRating": "הגדר דירוג תוכן",
"moveToFolder": "העבר לתיקייה",
"repairMetadata": "תיקון מטא-דאטה",
"reimportMetadata": "ייבא מחדש ממקור",
"excludeModel": "החרג מודל",
"restoreModel": "שחזור מודל",
"deleteModel": "מחק מודל",
@@ -864,6 +891,13 @@
"skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון",
"failed": "תיקון המתכון נכשל: {message}",
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
},
"reimport": {
"starting": "מייבא מתכון מחדש מהמקור...",
"success": "המתכון יובא מחדש בהצלחה",
"noSourceUrl": "למתכון אין כתובת מקור, לא ניתן לייבא מחדש",
"failed": "ייבוא המתכון מחדש נכשל: {message}",
"missingId": "לא ניתן לייבא מחדש: חסר מזהה מתכון"
}
},
"batchImport": {
@@ -942,8 +976,9 @@
"sidebar": {
"modelRoot": "שורש",
"collapseAll": "כווץ את כל התיקיות",
"pinSidebar": "נעל סרגל צד",
"unpinSidebar": "שחרר סרגל צד",
"hideOnThisPage": "הסתר סרגל צד בדף זה",
"showSidebar": "הצג סרגל צד",
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
"switchToListView": "עבור לתצוגת רשימה",
"switchToTreeView": "תצוגת עץ",
"recursiveOn": "כלול תיקיות משנה",
@@ -981,6 +1016,18 @@
"storage": "אחסון",
"insights": "תובנות"
},
"metrics": {
"totalModels": "סה\"כ דגמים",
"totalStorage": "סה\"כ אחסון",
"totalGenerations": "סה\"כ יצירות",
"usageRate": "שיעור שימוש",
"loras": "LoRA",
"checkpoints": "נקודות ביקורת",
"embeddings": "הטמעות",
"uniqueTags": "תגיות ייחודיות",
"unusedModels": "דגמים שאינם בשימוש",
"avgUsesPerModel": "ממוצע שימושים/דגם"
},
"usage": {
"mostUsedLoras": "LoRAs הנפוצים ביותר",
"mostUsedCheckpoints": "Checkpoints הנפוצים ביותר",
@@ -998,13 +1045,77 @@
},
"insights": {
"smartInsights": "תובנות חכמות",
"recommendations": "המלצות"
"recommendations": "המלצות",
"noInsights": "אין תובנות זמינות",
"unusedLoras": {
"high": {
"title": "כמות גבוהה של LoRAs שאינן בשימוש",
"description": "{percent}% מה-LoRAs שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
"suggestion": "שקול לארגן או לאחסן בארכיון מודלים שאינם בשימוש כדי לפנות שטח אחסון."
}
},
"unusedCheckpoints": {
"detected": {
"title": "התגלו נקודות ביקורת שאינן בשימוש",
"description": "{percent}% מנקודות הביקורת שלך ({count}/{total}) מעולם לא נעשה בהן שימוש.",
"suggestion": "בדוק ושקול להסיר נקודות ביקורת שאינך צריך עוד."
}
},
"unusedEmbeddings": {
"high": {
"title": "כמות גבוהה של Embeddings שאינם בשימוש",
"description": "{percent}% מה-Embeddings שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
"suggestion": "שקול לארגן או לאחסן בארכיון Embeddings שאינם בשימוש כדי לייעל את האוסף."
}
},
"collection": {
"large": {
"title": "התגלה אוסף גדול",
"description": "אוסף המודלים שלך משתמש ב-{size} של אחסון.",
"suggestion": "שקול להשתמש באחסון חיצוני או בפתרונות ענן לארגון טוב יותר."
}
},
"activity": {
"active": {
"title": "משתמש פעיל",
"description": "השלמת {count} יצירות עד כה!",
"suggestion": "המשך לחקור וליצור תוכן מדהים עם המודלים שלך."
}
}
},
"charts": {
"collectionOverview": "סקירת אוסף",
"baseModelDistribution": "התפלגות מודלי בסיס",
"usageTrends": "מגמות שימוש (30 יום אחרונים)",
"usageDistribution": "התפלגות שימוש"
"usageDistribution": "התפלגות שימוש",
"date": "תאריך",
"usageCount": "מספר שימושים",
"fileSizeBytes": "גודל קובץ (בתים)",
"models": "דגמים",
"loraUsage": "שימוש ב-LoRA",
"checkpointUsage": "שימוש ב-Checkpoint",
"embeddingUsage": "שימוש ב-Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "נקודת ביקורת",
"diffusion_model": "מודל דיפוזיה",
"embedding": "הטמעות"
},
"placeholders": {
"loading": "טוען...",
"noModels": "לא נמצאו דגמים",
"errorLoading": "שגיאה בטעינת נתונים",
"noStorageData": "אין נתוני אחסון זמינים",
"rootFolder": "שורש",
"chartLibraryMissing": "הגרף דורש את ספריית Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} דגמים",
"chartUsage": "{name}: {size}, {count} שימושים",
"chartPercentage": "{label}: {value} ({pct}%)"
}
},
"modals": {
@@ -1014,9 +1125,9 @@
"download": {
"title": "הורד מודל מכתובת URL",
"titleWithType": "הורד {type} מכתובת URL",
"url": "כתובת URL של Civitai",
"civitaiUrl": "כתובת URL של Civitai:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "יש להזין כתובת URL אחת של CivitAI או CivArchive בכל שורה. תומך במספר כתובות URL להורדה בבת אחת.",
"locationPreview": "תצוגה מקדימה של מיקום ההורדה",
"useDefaultPath": "השתמש בנתיב ברירת מחדל",
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
@@ -1225,7 +1336,9 @@
},
"notes": {
"saved": "הערות נשמרו בהצלחה",
"saveFailed": "שמירת ההערות נכשלה"
"saveFailed": "שמירת ההערות נכשלה",
"showMore": "הצג עוד",
"showLess": "הצג פחות"
},
"usageTips": {
"addPresetParameter": "הוסף פרמטר קבוע מראש...",
@@ -1378,6 +1491,21 @@
"versionDeleted": "הגרסה נמחקה"
}
}
},
"metadataFetchSummary": {
"title": "סיכום שליפת מטא-דאטה",
"statSuccess": "הצלחה",
"statFailed": "נכשל",
"statSkipped": "דולג",
"statTotal": "סה\"כ נסרק",
"statDuration": "משך",
"successMessage": "כל {count} {type}s עודכנו בהצלחה!",
"failedItems": "פריטים נכשלים ({count})",
"close": "סגור",
"copyReport": "העתק דוח",
"downloadCsv": "הורד CSV",
"columnModelName": "שם המודל",
"columnError": "שגיאה"
}
},
"modelTags": {
@@ -1391,15 +1519,6 @@
"duplicate": "תגית זו כבר קיימת"
}
},
"keyboard": {
"navigation": "ניווט במקלדת:",
"shortcuts": {
"pageUp": "גלול עמוד אחד למעלה",
"pageDown": "גלול עמוד אחד למטה",
"home": "קפוץ להתחלה",
"end": "קפוץ לסוף"
}
},
"initialization": {
"title": "מאתחל",
"message": "מכין את סביבת העבודה שלך...",
@@ -1487,11 +1606,14 @@
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noTargetNodeSelected": "לא נבחר צומת יעד",
"modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל"
"modelFailed": "עדכון צומת המודל נכשל",
"embeddingAdded": "Embedding נוסף ל-workflow",
"embeddingFailed": "הוספת Embedding נכשלה"
},
"nodeSelector": {
"recipe": "מתכון",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "החלף",
"append": "הוסף",
"selectTargetNode": "בחר צומת יעד",
@@ -1713,6 +1835,10 @@
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
"reimporting": "מייבא מתכון מחדש מהמקור...",
"reimportSuccess": "המתכון יובא מחדש בהצלחה",
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
},
@@ -1930,7 +2056,9 @@
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "הועתק ללוח",
"downloadStarted": "ההורדה החלה"
}
},
"doctor": {

View File

@@ -16,10 +16,13 @@
"help": "ヘルプ",
"add": "追加",
"close": "閉じる",
"menu": "メニュー"
"menu": "メニュー",
"remove": "削除",
"change": "変更"
},
"status": {
"loading": "読み込み中...",
"cancelling": "キャンセル中...",
"unknown": "不明",
"date": "日付",
"version": "バージョン",
@@ -111,6 +114,7 @@
"replacePreview": "プレビューを置換",
"copyCheckpointName": "checkpoint名をコピー",
"copyEmbeddingName": "embedding名をコピー",
"embeddingNameCopied": "Embedding構文をコピーしました",
"sendCheckpointToWorkflow": "ComfyUIに送信",
"sendEmbeddingToWorkflow": "ComfyUIに送信"
},
@@ -247,7 +251,18 @@
"toggle": "テーマの切り替え",
"switchToLight": "ライトテーマに切り替え",
"switchToDark": "ダークテーマに切り替え",
"switchToAuto": "自動テーマに切り替え"
"switchToAuto": "自動テーマに切り替え",
"presets": "テーマプリセット",
"default": "デフォルト",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "モード",
"light": "ライト",
"dark": "ダーク",
"auto": "自動"
},
"actions": {
"checkUpdates": "更新確認",
@@ -259,6 +274,9 @@
"civitaiApiKey": "Civitai APIキー",
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
"civitaiApiKeyConfigured": "設定済み",
"civitaiApiKeyNotConfigured": "未設定",
"civitaiApiKeySet": "設定",
"civitaiHost": {
"label": "Civitai ホスト",
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
@@ -299,6 +317,7 @@
"downloads": "ダウンロード",
"videoSettings": "動画設定",
"layoutSettings": "レイアウト設定",
"licenseIcons": "ライセンスアイコン",
"misc": "その他",
"backup": "バックアップ",
"folderSettings": "デフォルトルート",
@@ -445,7 +464,9 @@
"modelName": "モデル名",
"fileName": "ファイル名"
},
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択"
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択",
"cardBlurAmount": "カードオーバーレイのぼかし",
"cardBlurAmountHelp": "モデルカードとレシピカードのヘッダー・フッターオーバーレイのぼかし強度を調整します0 = ぼかしなし、20 = 最大ぼかし)。"
},
"folderSettings": {
"activeLibrary": "アクティブライブラリ",
@@ -577,6 +598,10 @@
"label": "早期アクセス更新を非表示",
"help": "早期アクセスのみの更新"
},
"licenseIcons": {
"useNewStyle": "更新されたライセンスアイコンを使用",
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示新スタイルするか、制限のみのアイコンを表示クラシックスタイルします。現在のCivitAIデザインを反映しています。"
},
"misc": {
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます",
@@ -690,6 +715,7 @@
"copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新",
"repairMetadata": "選択したレシピのメタデータを修復",
"reimportMetadata": "ソースから再インポート",
"checkUpdates": "選択項目の更新を確認",
"moveAll": "すべてをフォルダに移動",
"autoOrganize": "自動整理を実行",
@@ -737,6 +763,7 @@
"setContentRating": "コンテンツレーティングを設定",
"moveToFolder": "フォルダに移動",
"repairMetadata": "メタデータを修復",
"reimportMetadata": "ソースから再インポート",
"excludeModel": "モデルを除外",
"restoreModel": "モデルを復元",
"deleteModel": "モデルを削除",
@@ -864,6 +891,13 @@
"skipped": "レシピはすでに最新バージョンです。修復は不要です",
"failed": "レシピの修復に失敗しました: {message}",
"missingId": "レシピを修復できません: レシピIDがありません"
},
"reimport": {
"starting": "ソースからレシピを再インポート中...",
"success": "レシピの再インポートが完了しました",
"noSourceUrl": "レシピにソースURLがありません。再インポートできません",
"failed": "レシピの再インポートに失敗しました: {message}",
"missingId": "レシピを再インポートできません: レシピIDがありません"
}
},
"batchImport": {
@@ -942,8 +976,9 @@
"sidebar": {
"modelRoot": "ルート",
"collapseAll": "すべてのフォルダを折りたたむ",
"pinSidebar": "サイドバーを固定",
"unpinSidebar": "サイドバーの固定を解除",
"hideOnThisPage": "このページでサイドバーを非表示",
"showSidebar": "サイドバーを表示",
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
"switchToListView": "リストビューに切り替え",
"switchToTreeView": "ツリー表示に切り替え",
"recursiveOn": "サブフォルダーを含める",
@@ -981,6 +1016,18 @@
"storage": "ストレージ",
"insights": "インサイト"
},
"metrics": {
"totalModels": "モデル総数",
"totalStorage": "ストレージ合計",
"totalGenerations": "生成回数合計",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "ユニークタグ",
"unusedModels": "未使用モデル",
"avgUsesPerModel": "平均使用回数/モデル"
},
"usage": {
"mostUsedLoras": "最も使用されているLoRA",
"mostUsedCheckpoints": "最も使用されているCheckpoint",
@@ -998,13 +1045,77 @@
},
"insights": {
"smartInsights": "スマートインサイト",
"recommendations": "推奨事項"
"recommendations": "推奨事項",
"noInsights": "インサイトはありません",
"unusedLoras": {
"high": {
"title": "未使用のLoRAが多数あります",
"description": "LoRAの{percent}%{count}/{total})が一度も使用されていません。",
"suggestion": "未使用のモデルを整理またはアーカイブしてストレージを解放してください。"
}
},
"unusedCheckpoints": {
"detected": {
"title": "未使用のCheckpointを検出",
"description": "Checkpointの{percent}%{count}/{total})が一度も使用されていません。",
"suggestion": "不要なCheckpointを確認して削除を検討してください。"
}
},
"unusedEmbeddings": {
"high": {
"title": "未使用のEmbeddingが多数あります",
"description": "Embeddingの{percent}%{count}/{total})が一度も使用されていません。",
"suggestion": "未使用のEmbeddingを整理またはアーカイブしてコレクションを最適化してください。"
}
},
"collection": {
"large": {
"title": "大規模コレクションを検出",
"description": "モデルコレクションが{size}のストレージを使用しています。",
"suggestion": "外部ストレージやクラウドソリューションの使用を検討してください。"
}
},
"activity": {
"active": {
"title": "アクティブユーザー",
"description": "これまでに{count}回の生成を完了しました!",
"suggestion": "モデルを使って素晴らしいコンテンツを作り続けてください。"
}
}
},
"charts": {
"collectionOverview": "コレクション概要",
"baseModelDistribution": "ベースモデル分布",
"usageTrends": "使用傾向過去30日",
"usageDistribution": "使用分布"
"usageDistribution": "使用分布",
"date": "日付",
"usageCount": "使用回数",
"fileSizeBytes": "ファイルサイズ(バイト)",
"models": "モデル",
"loraUsage": "LoRA 使用量",
"checkpointUsage": "Checkpoint 使用量",
"embeddingUsage": "Embedding 使用量"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "拡散モデル",
"embedding": "Embedding"
},
"placeholders": {
"loading": "読み込み中...",
"noModels": "モデルが見つかりません",
"errorLoading": "データ読み込みエラー",
"noStorageData": "ストレージデータがありません",
"rootFolder": "ルート",
"chartLibraryMissing": "Chart.js ライブラリが必要です"
},
"tooltips": {
"tagCount": "{tag}: {count} モデル",
"chartUsage": "{name}: {size}, {count} 回使用",
"chartPercentage": "{label}: {value} ({pct}%)"
}
},
"modals": {
@@ -1014,9 +1125,9 @@
"download": {
"title": "URLからモデルをダウンロード",
"titleWithType": "URLから{type}をダウンロード",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL",
"placeholder": "https://civitai.com/models/...",
"urlHint": "1行に1つのCivitAIまたはCivArchive URLを入力してください。複数のURLを一括ダウンロードできます。",
"locationPreview": "ダウンロード場所プレビュー",
"useDefaultPath": "デフォルトパスを使用",
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
@@ -1225,7 +1336,9 @@
},
"notes": {
"saved": "メモが正常に保存されました",
"saveFailed": "メモの保存に失敗しました"
"saveFailed": "メモの保存に失敗しました",
"showMore": "もっと見る",
"showLess": "折りたたむ"
},
"usageTips": {
"addPresetParameter": "プリセットパラメータを追加...",
@@ -1378,6 +1491,21 @@
"versionDeleted": "バージョンを削除しました"
}
}
},
"metadataFetchSummary": {
"title": "メタデータ取得サマリー",
"statSuccess": "成功",
"statFailed": "失敗",
"statSkipped": "スキップ",
"statTotal": "スキャン合計",
"statDuration": "所要時間",
"successMessage": "すべての{count}件の{type}を正常に更新しました",
"failedItems": "失敗したアイテム ({count})",
"close": "閉じる",
"copyReport": "レポートをコピー",
"downloadCsv": "CSVをダウンロード",
"columnModelName": "モデル名",
"columnError": "エラー"
}
},
"modelTags": {
@@ -1391,15 +1519,6 @@
"duplicate": "このタグは既に存在します"
}
},
"keyboard": {
"navigation": "キーボードナビゲーション:",
"shortcuts": {
"pageUp": "1ページ上にスクロール",
"pageDown": "1ページ下にスクロール",
"home": "トップにジャンプ",
"end": "ボトムにジャンプ"
}
},
"initialization": {
"title": "初期化中",
"message": "ワークスペースを準備中...",
@@ -1487,11 +1606,14 @@
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noTargetNodeSelected": "ターゲットノードが選択されていません",
"modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました"
"modelFailed": "モデルノードの更新に失敗しました",
"embeddingAdded": "Embeddingをワークフローに追加しました",
"embeddingFailed": "Embeddingの追加に失敗しました"
},
"nodeSelector": {
"recipe": "レシピ",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "置換",
"append": "追加",
"selectTargetNode": "ターゲットノードを選択",
@@ -1713,6 +1835,10 @@
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
"reimporting": "ソースからレシピを再インポート中...",
"reimportSuccess": "レシピの再インポートが完了しました",
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
},
@@ -1930,7 +2056,9 @@
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "クリップボードにコピーしました",
"downloadStarted": "ダウンロードを開始しました"
}
},
"doctor": {

View File

@@ -16,10 +16,13 @@
"help": "도움말",
"add": "추가",
"close": "닫기",
"menu": "메뉴"
"menu": "메뉴",
"remove": "제거",
"change": "변경"
},
"status": {
"loading": "로딩 중...",
"cancelling": "취소 중...",
"unknown": "알 수 없음",
"date": "날짜",
"version": "버전",
@@ -111,6 +114,7 @@
"replacePreview": "미리보기 교체",
"copyCheckpointName": "Checkpoint 이름 복사",
"copyEmbeddingName": "Embedding 이름 복사",
"embeddingNameCopied": "Embedding 구문 복사됨",
"sendCheckpointToWorkflow": "ComfyUI로 전송",
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
},
@@ -247,7 +251,18 @@
"toggle": "테마 토글",
"switchToLight": "라이트 테마로 전환",
"switchToDark": "다크 테마로 전환",
"switchToAuto": "자동 테마로 전환"
"switchToAuto": "자동 테마로 전환",
"presets": "테마 프리셋",
"default": "기본",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "모드",
"light": "라이트",
"dark": "다크",
"auto": "자동"
},
"actions": {
"checkUpdates": "업데이트 확인",
@@ -259,6 +274,9 @@
"civitaiApiKey": "Civitai API 키",
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
"civitaiApiKeyConfigured": "설정됨",
"civitaiApiKeyNotConfigured": "설정되지 않음",
"civitaiApiKeySet": "설정",
"civitaiHost": {
"label": "Civitai 호스트",
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
@@ -299,6 +317,7 @@
"downloads": "다운로드",
"videoSettings": "비디오 설정",
"layoutSettings": "레이아웃 설정",
"licenseIcons": "라이선스 아이콘",
"misc": "기타",
"backup": "백업",
"folderSettings": "기본 루트",
@@ -445,7 +464,9 @@
"modelName": "모델명",
"fileName": "파일명"
},
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요"
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요",
"cardBlurAmount": "카드 오버레이 흐림 강도",
"cardBlurAmountHelp": "모델 및 레시피 카드의 헤더와 푸터 오버레이 흐림 강도를 조정합니다 (0 = 흐림 없음, 20 = 최대 흐림)."
},
"folderSettings": {
"activeLibrary": "활성 라이브러리",
@@ -577,6 +598,10 @@
"label": "얼리 액세스 업데이트 숨기기",
"help": "얼리 액세스 업데이트만"
},
"licenseIcons": {
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
},
"misc": {
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다",
@@ -690,6 +715,7 @@
"copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침",
"repairMetadata": "선택한 레시피 메타데이터 복구",
"reimportMetadata": "소스에서 다시 가져오기",
"checkUpdates": "선택 항목 업데이트 확인",
"moveAll": "모두 폴더로 이동",
"autoOrganize": "자동 정리 선택",
@@ -737,6 +763,7 @@
"setContentRating": "콘텐츠 등급 설정",
"moveToFolder": "폴더로 이동",
"repairMetadata": "메타데이터 복구",
"reimportMetadata": "소스에서 다시 가져오기",
"excludeModel": "모델 제외",
"restoreModel": "모델 복원",
"deleteModel": "모델 삭제",
@@ -864,6 +891,13 @@
"skipped": "레시피가 이미 최신 버전입니다. 복구가 필요하지 않습니다",
"failed": "레시피 복구 실패: {message}",
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
},
"reimport": {
"starting": "소스에서 레시피를 다시 가져오는 중...",
"success": "레시피를 다시 가져왔습니다",
"noSourceUrl": "레시피에 소스 URL이 없어 다시 가져올 수 없습니다",
"failed": "레시피 다시 가져오기 실패: {message}",
"missingId": "레시피를 다시 가져올 수 없음: 레시피 ID 누락"
}
},
"batchImport": {
@@ -942,8 +976,9 @@
"sidebar": {
"modelRoot": "루트",
"collapseAll": "모든 폴더 접기",
"pinSidebar": "사이드바 고정",
"unpinSidebar": "사이드바 고정 해제",
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
"showSidebar": "사이드바 표시",
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
"switchToListView": "목록 보기로 전환",
"switchToTreeView": "트리 보기로 전환",
"recursiveOn": "하위 폴더 포함",
@@ -981,6 +1016,18 @@
"storage": "저장소",
"insights": "인사이트"
},
"metrics": {
"totalModels": "모델 총계",
"totalStorage": "총 저장 공간",
"totalGenerations": "총 생성 횟수",
"usageRate": "사용률",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "고유 태그",
"unusedModels": "미사용 모델",
"avgUsesPerModel": "모델당 평균 사용"
},
"usage": {
"mostUsedLoras": "가장 많이 사용된 LoRA",
"mostUsedCheckpoints": "가장 많이 사용된 Checkpoint",
@@ -998,13 +1045,77 @@
},
"insights": {
"smartInsights": "스마트 인사이트",
"recommendations": "추천"
"recommendations": "추천",
"noInsights": "인사이트 없음",
"unusedLoras": {
"high": {
"title": "사용하지 않은 LoRA가 많음",
"description": "LoRA의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
"suggestion": "사용하지 않는 모델을 정리하거나 보관하여 저장 공간을 확보하세요."
}
},
"unusedCheckpoints": {
"detected": {
"title": "사용하지 않은 Checkpoint 감지",
"description": "Checkpoint의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
"suggestion": "더 이상 필요하지 않은 Checkpoint를 검토하고 제거하세요."
}
},
"unusedEmbeddings": {
"high": {
"title": "사용하지 않은 Embedding이 많음",
"description": "Embedding의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
"suggestion": "사용하지 않는 Embedding을 정리하여 컬렉션을 최적화하세요."
}
},
"collection": {
"large": {
"title": "대규모 컬렉션 감지",
"description": "모델 컬렉션이 {size}의 저장 공간을 사용 중입니다.",
"suggestion": "더 나은 관리를 위해 외부 저장소나 클라우드 솔루션을 고려하세요."
}
},
"activity": {
"active": {
"title": "활성 사용자",
"description": "지금까지 {count}번의 생성을 완료했습니다!",
"suggestion": "모델로 계속해서 멋진 콘텐츠를 탐색하고 만들어보세요."
}
}
},
"charts": {
"collectionOverview": "컬렉션 개요",
"baseModelDistribution": "베이스 모델 분포",
"usageTrends": "사용량 트렌드 (최근 30일)",
"usageDistribution": "사용량 분포"
"usageDistribution": "사용량 분포",
"date": "날짜",
"usageCount": "사용 횟수",
"fileSizeBytes": "파일 크기(바이트)",
"models": "모델",
"loraUsage": "LoRA 사용량",
"checkpointUsage": "Checkpoint 사용량",
"embeddingUsage": "Embedding 사용량"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "확산 모델",
"embedding": "Embedding"
},
"placeholders": {
"loading": "로딩 중...",
"noModels": "모델을 찾을 수 없음",
"errorLoading": "데이터 로딩 오류",
"noStorageData": "저장 데이터 없음",
"rootFolder": "루트",
"chartLibraryMissing": "Chart.js 라이브러리가 필요합니다"
},
"tooltips": {
"tagCount": "{tag}: {count}개 모델",
"chartUsage": "{name}: {size}, {count}회 사용",
"chartPercentage": "{label}: {value}({pct}%)"
}
},
"modals": {
@@ -1014,9 +1125,9 @@
"download": {
"title": "URL에서 모델 다운로드",
"titleWithType": "URL에서 {type} 다운로드",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "한 줄에 하나의 CivitAI 또는 CivArchive URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
"locationPreview": "다운로드 위치 미리보기",
"useDefaultPath": "기본 경로 사용",
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
@@ -1225,7 +1336,9 @@
},
"notes": {
"saved": "메모가 성공적으로 저장됨",
"saveFailed": "메모 저장 실패"
"saveFailed": "메모 저장 실패",
"showMore": "더 보기",
"showLess": "접기"
},
"usageTips": {
"addPresetParameter": "프리셋 매개변수 추가...",
@@ -1378,6 +1491,21 @@
"versionDeleted": "버전이 삭제되었습니다"
}
}
},
"metadataFetchSummary": {
"title": "메타데이터 가져오기 요약",
"statSuccess": "성공",
"statFailed": "실패",
"statSkipped": "건너뜀",
"statTotal": "총 스캔",
"statDuration": "소요 시간",
"successMessage": "모든 {count}개 {type}이(가) 성공적으로 업데이트되었습니다",
"failedItems": "실패한 항목 ({count})",
"close": "닫기",
"copyReport": "보고서 복사",
"downloadCsv": "CSV 다운로드",
"columnModelName": "모델 이름",
"columnError": "오류"
}
},
"modelTags": {
@@ -1391,15 +1519,6 @@
"duplicate": "이 태그는 이미 존재합니다"
}
},
"keyboard": {
"navigation": "키보드 내비게이션:",
"shortcuts": {
"pageUp": "한 페이지 위로 스크롤",
"pageDown": "한 페이지 아래로 스크롤",
"home": "맨 위로 이동",
"end": "맨 아래로 이동"
}
},
"initialization": {
"title": "초기화 중",
"message": "작업공간을 준비하고 있습니다...",
@@ -1487,11 +1606,14 @@
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패"
"modelFailed": "모델 노드 업데이트 실패",
"embeddingAdded": "Embedding을 워크플로에 추가했습니다",
"embeddingFailed": "Embedding 추가 실패"
},
"nodeSelector": {
"recipe": "레시피",
"lora": "LoRA",
"embedding": "임베딩",
"replace": "교체",
"append": "추가",
"selectTargetNode": "대상 노드 선택",
@@ -1713,6 +1835,10 @@
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
"reimportSuccess": "레시피를 다시 가져왔습니다",
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
},
@@ -1930,7 +2056,9 @@
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "클립보드에 복사됨",
"downloadStarted": "다운로드 시작됨"
}
},
"doctor": {

View File

@@ -16,10 +16,13 @@
"help": "Справка",
"add": "Добавить",
"close": "Закрыть",
"menu": "Меню"
"menu": "Меню",
"remove": "Удалить",
"change": "Изменить"
},
"status": {
"loading": "Загрузка...",
"cancelling": "Отмена...",
"unknown": "Неизвестно",
"date": "Дата",
"version": "Версия",
@@ -111,6 +114,7 @@
"replacePreview": "Заменить превью",
"copyCheckpointName": "Копировать имя checkpoint",
"copyEmbeddingName": "Копировать имя embedding",
"embeddingNameCopied": "Синтаксис embedding скопирован",
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
},
@@ -247,7 +251,18 @@
"toggle": "Переключить тему",
"switchToLight": "Переключить на светлую тему",
"switchToDark": "Переключить на тёмную тему",
"switchToAuto": "Переключить на автоматическую тему"
"switchToAuto": "Переключить на автоматическую тему",
"presets": "Предустановки тем",
"default": "По умолчанию",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "Режим",
"light": "Светлый",
"dark": "Тёмный",
"auto": "Авто"
},
"actions": {
"checkUpdates": "Проверить обновления",
@@ -259,6 +274,9 @@
"civitaiApiKey": "Ключ API Civitai",
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
"civitaiApiKeyConfigured": "Настроен",
"civitaiApiKeyNotConfigured": "Не настроен",
"civitaiApiKeySet": "Настроить",
"civitaiHost": {
"label": "Хост Civitai",
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
@@ -299,6 +317,7 @@
"downloads": "Загрузки",
"videoSettings": "Настройки видео",
"layoutSettings": "Настройки макета",
"licenseIcons": "Значки лицензии",
"misc": "Разное",
"backup": "Резервные копии",
"folderSettings": "Корневые папки",
@@ -445,7 +464,9 @@
"modelName": "Название модели",
"fileName": "Имя файла"
},
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели"
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели",
"cardBlurAmount": "Размытие наложения карточек",
"cardBlurAmountHelp": "Настройте интенсивность размытия наложений верхнего и нижнего колонтитулов на карточках моделей и рецептов (0 = без размытия, 20 = максимальное размытие)."
},
"folderSettings": {
"activeLibrary": "Активная библиотека",
@@ -577,6 +598,10 @@
"label": "Скрыть обновления раннего доступа",
"help": "Только обновления раннего доступа"
},
"licenseIcons": {
"useNewStyle": "Использовать обновлённые значки лицензии",
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
},
"misc": {
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена",
@@ -690,6 +715,7 @@
"copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные",
"repairMetadata": "Восстановить метаданные для выбранных",
"reimportMetadata": "Переимпортировать из источника",
"checkUpdates": "Проверить обновления для выбранных",
"moveAll": "Переместить все в папку",
"autoOrganize": "Автоматически организовать выбранные",
@@ -737,6 +763,7 @@
"setContentRating": "Установить рейтинг контента",
"moveToFolder": "Переместить в папку",
"repairMetadata": "Восстановить метаданные",
"reimportMetadata": "Переимпортировать из источника",
"excludeModel": "Исключить модель",
"restoreModel": "Восстановить модель",
"deleteModel": "Удалить модель",
@@ -864,6 +891,13 @@
"skipped": "Рецепт уже последней версии, восстановление не требуется",
"failed": "Не удалось восстановить рецепт: {message}",
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
},
"reimport": {
"starting": "Переимпорт рецепта из источника...",
"success": "Рецепт успешно переимпортирован",
"noSourceUrl": "У рецепта нет URL источника, переимпорт невозможен",
"failed": "Не удалось переимпортировать рецепт: {message}",
"missingId": "Невозможно переимпортировать рецепт: отсутствует ID"
}
},
"batchImport": {
@@ -942,8 +976,9 @@
"sidebar": {
"modelRoot": "Корень",
"collapseAll": "Свернуть все папки",
"pinSidebar": "Закрепить боковую панель",
"unpinSidebar": "Открепить боковую панель",
"hideOnThisPage": "Скрыть боковую панель на этой странице",
"showSidebar": "Показать боковую панель",
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
"switchToListView": "Переключить на вид списка",
"switchToTreeView": "Переключить на древовидный вид",
"recursiveOn": "Включать вложенные папки",
@@ -981,6 +1016,18 @@
"storage": "Хранение",
"insights": "Аналитика"
},
"metrics": {
"totalModels": "Всего моделей",
"totalStorage": "Всего хранилища",
"totalGenerations": "Всего генераций",
"usageRate": "Коэффициент использования",
"loras": "LoRA",
"checkpoints": "Контрольные точки",
"embeddings": "Эмбеддинги",
"uniqueTags": "Уникальные теги",
"unusedModels": "Неиспользуемые модели",
"avgUsesPerModel": "Сред. использований/модель"
},
"usage": {
"mostUsedLoras": "Наиболее используемые LoRAs",
"mostUsedCheckpoints": "Наиболее используемые Checkpoints",
@@ -998,13 +1045,77 @@
},
"insights": {
"smartInsights": "Умная аналитика",
"recommendations": "Рекомендации"
"recommendations": "Рекомендации",
"noInsights": "Нет доступных данных",
"unusedLoras": {
"high": {
"title": "Большое количество неиспользуемых LoRA",
"description": "{percent}% ваших LoRA ({count}/{total}) никогда не использовались.",
"suggestion": "Рассмотрите возможность организации или архивирования неиспользуемых моделей для освобождения места."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Обнаружены неиспользуемые контрольные точки",
"description": "{percent}% ваших контрольных точек ({count}/{total}) никогда не использовались.",
"suggestion": "Проверьте и удалите ненужные контрольные точки."
}
},
"unusedEmbeddings": {
"high": {
"title": "Большое количество неиспользуемых эмбеддингов",
"description": "{percent}% ваших эмбеддингов ({count}/{total}) никогда не использовались.",
"suggestion": "Организуйте или архивируйте неиспользуемые эмбеддинги для оптимизации коллекции."
}
},
"collection": {
"large": {
"title": "Обнаружена большая коллекция",
"description": "Ваша коллекция моделей использует {size} хранилища.",
"suggestion": "Рассмотрите внешнее хранилище или облачные решения для лучшей организации."
}
},
"activity": {
"active": {
"title": "Активный пользователь",
"description": "Вы завершили {count} генераций!",
"suggestion": "Продолжайте исследовать и создавать удивительный контент с вашими моделями."
}
}
},
"charts": {
"collectionOverview": "Обзор коллекции",
"baseModelDistribution": "Распределение базовых моделей",
"usageTrends": "Тенденции использования (за последние 30 дней)",
"usageDistribution": "Распределение использования"
"usageDistribution": "Распределение использования",
"date": "Дата",
"usageCount": "Количество использований",
"fileSizeBytes": "Размер файла (байты)",
"models": "Модели",
"loraUsage": "Использование LoRA",
"checkpointUsage": "Использование Checkpoint",
"embeddingUsage": "Использование Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Контрольная точка",
"diffusion_model": "Диффузионная модель",
"embedding": "Эмбеддинги"
},
"placeholders": {
"loading": "Загрузка...",
"noModels": "Модели не найдены",
"errorLoading": "Ошибка загрузки данных",
"noStorageData": "Нет данных о хранилище",
"rootFolder": "Корень",
"chartLibraryMissing": "Для графика требуется библиотека Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} моделей",
"chartUsage": "{name}: {size}, {count} использований",
"chartPercentage": "{label}: {value} ({pct}%)"
}
},
"modals": {
@@ -1014,9 +1125,9 @@
"download": {
"title": "Скачать модель по URL",
"titleWithType": "Скачать {type} по URL",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Введите один URL CivitAI или CivArchive в каждой строке. Поддерживается пакетная загрузка нескольких URL.",
"locationPreview": "Предпросмотр места загрузки",
"useDefaultPath": "Использовать путь по умолчанию",
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
@@ -1225,7 +1336,9 @@
},
"notes": {
"saved": "Заметки успешно сохранены",
"saveFailed": "Не удалось сохранить заметки"
"saveFailed": "Не удалось сохранить заметки",
"showMore": "Показать больше",
"showLess": "Свернуть"
},
"usageTips": {
"addPresetParameter": "Добавить предустановленный параметр...",
@@ -1378,6 +1491,21 @@
"versionDeleted": "Версия удалена"
}
}
},
"metadataFetchSummary": {
"title": "Сводка получения метаданных",
"statSuccess": "Успешно",
"statFailed": "Ошибка",
"statSkipped": "Пропущено",
"statTotal": "Всего проверено",
"statDuration": "Длительность",
"successMessage": "Все {count} {type}s успешно обновлены",
"failedItems": "Ошибочные элементы ({count})",
"close": "Закрыть",
"copyReport": "Копировать отчет",
"downloadCsv": "Скачать CSV",
"columnModelName": "Имя модели",
"columnError": "Ошибка"
}
},
"modelTags": {
@@ -1391,15 +1519,6 @@
"duplicate": "Этот тег уже существует"
}
},
"keyboard": {
"navigation": "Навигация с клавиатуры:",
"shortcuts": {
"pageUp": "Прокрутить на страницу вверх",
"pageDown": "Прокрутить на страницу вниз",
"home": "Перейти к началу",
"end": "Перейти к концу"
}
},
"initialization": {
"title": "Инициализация",
"message": "Подготовка вашего рабочего пространства...",
@@ -1487,11 +1606,14 @@
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noTargetNodeSelected": "Целевой узел не выбран",
"modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели"
"modelFailed": "Не удалось обновить узел модели",
"embeddingAdded": "Embedding добавлен в workflow",
"embeddingFailed": "Не удалось добавить embedding"
},
"nodeSelector": {
"recipe": "Рецепт",
"lora": "LoRA",
"embedding": "Эмбеддинг",
"replace": "Заменить",
"append": "Добавить",
"selectTargetNode": "Выберите целевой узел",
@@ -1713,6 +1835,10 @@
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
"reimporting": "Переимпорт рецепта из источника...",
"reimportSuccess": "Рецепт успешно переимпортирован",
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
},
@@ -1930,7 +2056,9 @@
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Скопировано в буфер обмена",
"downloadStarted": "Загрузка начата"
}
},
"doctor": {

View File

@@ -16,10 +16,13 @@
"help": "帮助",
"add": "添加",
"close": "关闭",
"menu": "菜单"
"menu": "菜单",
"remove": "移除",
"change": "更换"
},
"status": {
"loading": "加载中...",
"cancelling": "取消中...",
"unknown": "未知",
"date": "日期",
"version": "版本",
@@ -111,6 +114,7 @@
"replacePreview": "替换预览",
"copyCheckpointName": "复制 Checkpoint 名称",
"copyEmbeddingName": "复制 Embedding 名称",
"embeddingNameCopied": "已复制 Embedding 语法",
"sendCheckpointToWorkflow": "发送到 ComfyUI",
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
},
@@ -247,7 +251,18 @@
"toggle": "切换主题",
"switchToLight": "切换到浅色主题",
"switchToDark": "切换到深色主题",
"switchToAuto": "切换到自动主题"
"switchToAuto": "切换到自动主题",
"presets": "主题预设",
"default": "默认",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "模式",
"light": "浅色",
"dark": "深色",
"auto": "自动"
},
"actions": {
"checkUpdates": "检查更新",
@@ -259,6 +274,9 @@
"civitaiApiKey": "Civitai API 密钥",
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
"civitaiApiKeyConfigured": "已配置",
"civitaiApiKeyNotConfigured": "未配置",
"civitaiApiKeySet": "设置",
"civitaiHost": {
"label": "Civitai 站点",
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
@@ -299,6 +317,7 @@
"downloads": "下载",
"videoSettings": "视频设置",
"layoutSettings": "布局设置",
"licenseIcons": "许可协议图标",
"misc": "其他",
"backup": "备份",
"folderSettings": "默认根目录",
@@ -445,7 +464,9 @@
"modelName": "模型名称",
"fileName": "文件名"
},
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容"
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容",
"cardBlurAmount": "卡片叠加模糊强度",
"cardBlurAmountHelp": "调整模型和配方卡片上页眉和页脚叠加层的模糊强度0 = 无模糊20 = 最大模糊)。"
},
"folderSettings": {
"activeLibrary": "活动库",
@@ -577,6 +598,10 @@
"label": "隐藏抢先体验更新",
"help": "抢先体验更新"
},
"licenseIcons": {
"useNewStyle": "使用新版许可协议图标",
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
},
"misc": {
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词",
@@ -690,6 +715,7 @@
"copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据",
"repairMetadata": "修复所选中元数据",
"reimportMetadata": "从源重新导入",
"checkUpdates": "检查所选更新",
"moveAll": "移动所选中到文件夹",
"autoOrganize": "自动整理所选模型",
@@ -737,6 +763,7 @@
"setContentRating": "设置内容评级",
"moveToFolder": "移动到文件夹",
"repairMetadata": "修复元数据",
"reimportMetadata": "从源重新导入",
"excludeModel": "排除模型",
"restoreModel": "恢复模型",
"deleteModel": "删除模型",
@@ -864,6 +891,13 @@
"skipped": "配方已是最新版本,无需修复",
"failed": "修复配方失败:{message}",
"missingId": "无法修复配方:缺少配方 ID"
},
"reimport": {
"starting": "正在从源重新导入配方...",
"success": "配方已从源重新导入成功",
"noSourceUrl": "配方没有源URL无法重新导入",
"failed": "重新导入配方失败:{message}",
"missingId": "无法重新导入配方缺少配方ID"
}
},
"batchImport": {
@@ -942,8 +976,9 @@
"sidebar": {
"modelRoot": "根目录",
"collapseAll": "折叠所有文件夹",
"pinSidebar": "固定侧边栏",
"unpinSidebar": "取消固定侧边栏",
"hideOnThisPage": "隐藏此页面侧边栏",
"showSidebar": "显示侧边栏",
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
"switchToListView": "切换到列表视图",
"switchToTreeView": "切换到树状视图",
"recursiveOn": "包含子文件夹",
@@ -981,6 +1016,18 @@
"storage": "存储",
"insights": "洞察"
},
"metrics": {
"totalModels": "模型总数",
"totalStorage": "总存储空间",
"totalGenerations": "总生成次数",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "唯一标签",
"unusedModels": "未使用模型",
"avgUsesPerModel": "平均使用次数/模型"
},
"usage": {
"mostUsedLoras": "最常用 LoRA",
"mostUsedCheckpoints": "最常用 Checkpoint",
@@ -998,13 +1045,77 @@
},
"insights": {
"smartInsights": "智能洞察",
"recommendations": "推荐"
"recommendations": "推荐",
"noInsights": "暂无可用洞察",
"unusedLoras": {
"high": {
"title": "大量未使用的 LoRA",
"description": "你的 LoRA 中有 {percent}%{count}/{total})从未被使用过。",
"suggestion": "考虑整理或归档未使用的模型以释放存储空间。"
}
},
"unusedCheckpoints": {
"detected": {
"title": "检测到未使用的 Checkpoint",
"description": "你的 Checkpoint 中有 {percent}%{count}/{total})从未被使用过。",
"suggestion": "审查并考虑删除不再需要的 Checkpoint。"
}
},
"unusedEmbeddings": {
"high": {
"title": "大量未使用的 Embedding",
"description": "你的 Embedding 中有 {percent}%{count}/{total})从未被使用过。",
"suggestion": "考虑整理或归档未使用的 Embedding 以优化你的收藏。"
}
},
"collection": {
"large": {
"title": "检测到大型收藏",
"description": "你的模型收藏正在使用 {size} 的存储空间。",
"suggestion": "考虑使用外部存储或云解决方案以获得更好的组织。"
}
},
"activity": {
"active": {
"title": "活跃用户",
"description": "你已经完成了 {count} 次生成!",
"suggestion": "继续探索并用你的模型创作精彩内容。"
}
}
},
"charts": {
"collectionOverview": "收藏概览",
"baseModelDistribution": "基础模型分布",
"usageTrends": "使用趋势最近30天",
"usageDistribution": "使用分布"
"usageDistribution": "使用分布",
"date": "日期",
"usageCount": "使用次数",
"fileSizeBytes": "文件大小(字节)",
"models": "模型",
"loraUsage": "LoRA 使用量",
"checkpointUsage": "Checkpoint 使用量",
"embeddingUsage": "Embedding 使用量"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "扩散模型",
"embedding": "Embedding"
},
"placeholders": {
"loading": "加载中...",
"noModels": "未找到模型",
"errorLoading": "数据加载失败",
"noStorageData": "暂无存储数据",
"rootFolder": "根目录",
"chartLibraryMissing": "需要 Chart.js 库来显示图表"
},
"tooltips": {
"tagCount": "{tag}{count} 个模型",
"chartUsage": "{name}{size}{count} 次使用",
"chartPercentage": "{label}{value}{pct}%"
}
},
"modals": {
@@ -1014,9 +1125,9 @@
"download": {
"title": "从 URL 下载模型",
"titleWithType": "从 URL 下载 {type}",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "每行输入一个 CivitAI 或 CivArchive URL。支持批量下载多个 URL。",
"locationPreview": "下载位置预览",
"useDefaultPath": "使用默认路径",
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
@@ -1225,7 +1336,9 @@
},
"notes": {
"saved": "备注保存成功",
"saveFailed": "备注保存失败"
"saveFailed": "备注保存失败",
"showMore": "展开",
"showLess": "收起"
},
"usageTips": {
"addPresetParameter": "添加预设参数...",
@@ -1378,6 +1491,21 @@
"versionDeleted": "版本已删除"
}
}
},
"metadataFetchSummary": {
"title": "元数据获取摘要",
"statSuccess": "成功",
"statFailed": "失败",
"statSkipped": "已跳过",
"statTotal": "总计扫描",
"statDuration": "耗时",
"successMessage": "全部 {count} 个 {type} 更新成功!",
"failedItems": "失败项目 ({count})",
"close": "关闭",
"copyReport": "复制报告",
"downloadCsv": "下载 CSV",
"columnModelName": "模型名称",
"columnError": "错误"
}
},
"modelTags": {
@@ -1391,15 +1519,6 @@
"duplicate": "该标签已存在"
}
},
"keyboard": {
"navigation": "键盘导航:",
"shortcuts": {
"pageUp": "向上一页滚动",
"pageDown": "向下一页滚动",
"home": "跳到顶部",
"end": "跳到底部"
}
},
"initialization": {
"title": "初始化",
"message": "正在准备你的工作空间...",
@@ -1487,11 +1606,14 @@
"noMatchingNodes": "当前工作流中没有兼容的节点",
"noTargetNodeSelected": "未选择目标节点",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型节点失败"
"modelFailed": "更新模型节点失败",
"embeddingAdded": "Embedding 已追加到工作流",
"embeddingFailed": "添加 Embedding 失败"
},
"nodeSelector": {
"recipe": "配方",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "替换",
"append": "追加",
"selectTargetNode": "选择目标节点",
@@ -1713,6 +1835,10 @@
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
"repairBulkSkipped": "所选 {total} 个配方无需修复",
"repairBulkFailed": "修复所选配方失败:{message}",
"reimporting": "正在从源重新导入配方...",
"reimportSuccess": "配方已从源重新导入成功",
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
"reimportBulkFailed": "重新导入某些配方失败",
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
},
@@ -1930,7 +2056,9 @@
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
"exampleImagesDownloadSuccess": "示例图片下载成功!",
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "已复制到剪贴板",
"downloadStarted": "下载已开始"
}
},
"doctor": {

View File

@@ -16,10 +16,13 @@
"help": "說明",
"add": "新增",
"close": "關閉",
"menu": "選單"
"menu": "選單",
"remove": "移除",
"change": "更換"
},
"status": {
"loading": "載入中...",
"cancelling": "取消中...",
"unknown": "未知",
"date": "日期",
"version": "版本",
@@ -111,6 +114,7 @@
"replacePreview": "更換預覽圖",
"copyCheckpointName": "複製檢查點名稱",
"copyEmbeddingName": "複製嵌入名稱",
"embeddingNameCopied": "已複製 Embedding 語法",
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
},
@@ -247,7 +251,18 @@
"toggle": "切換主題",
"switchToLight": "切換至淺色主題",
"switchToDark": "切換至深色主題",
"switchToAuto": "自動主題"
"switchToAuto": "自動主題",
"presets": "主題預設",
"default": "預設",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "模式",
"light": "淺色",
"dark": "深色",
"auto": "自動"
},
"actions": {
"checkUpdates": "檢查更新",
@@ -259,6 +274,9 @@
"civitaiApiKey": "Civitai API 金鑰",
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
"civitaiApiKeyConfigured": "已設定",
"civitaiApiKeyNotConfigured": "未設定",
"civitaiApiKeySet": "設定",
"civitaiHost": {
"label": "Civitai 站點",
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
@@ -299,6 +317,7 @@
"downloads": "下載",
"videoSettings": "影片設定",
"layoutSettings": "版面設定",
"licenseIcons": "許可協議圖標",
"misc": "其他",
"backup": "備份",
"folderSettings": "預設根目錄",
@@ -445,7 +464,9 @@
"modelName": "模型名稱",
"fileName": "檔案名稱"
},
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容"
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容",
"cardBlurAmount": "卡片疊加模糊強度",
"cardBlurAmountHelp": "調整模型和配方卡片上頁首和頁尾疊加層的模糊強度0 = 無模糊20 = 最大模糊)。"
},
"folderSettings": {
"activeLibrary": "使用中的資料庫",
@@ -577,6 +598,10 @@
"label": "隱藏搶先體驗更新",
"help": "搶先體驗更新"
},
"licenseIcons": {
"useNewStyle": "使用新版許可協議圖標",
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
},
"misc": {
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞",
@@ -690,6 +715,7 @@
"copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata",
"repairMetadata": "修復所選中元數據",
"reimportMetadata": "從來源重新匯入",
"checkUpdates": "檢查所選更新",
"moveAll": "全部移動到資料夾",
"autoOrganize": "自動整理所選模型",
@@ -737,6 +763,7 @@
"setContentRating": "設定內容分級",
"moveToFolder": "移動到資料夾",
"repairMetadata": "修復元數據",
"reimportMetadata": "從來源重新匯入",
"excludeModel": "排除模型",
"restoreModel": "還原模型",
"deleteModel": "刪除模型",
@@ -864,6 +891,13 @@
"skipped": "配方已是最新版本,無需修復",
"failed": "修復配方失敗:{message}",
"missingId": "無法修復配方:缺少配方 ID"
},
"reimport": {
"starting": "正在從來源重新匯入配方...",
"success": "配方已從來源重新匯入成功",
"noSourceUrl": "配方沒有來源URL無法重新匯入",
"failed": "重新匯入配方失敗:{message}",
"missingId": "無法重新匯入配方缺少配方ID"
}
},
"batchImport": {
@@ -942,8 +976,9 @@
"sidebar": {
"modelRoot": "根目錄",
"collapseAll": "全部摺疊資料夾",
"pinSidebar": "固定側邊欄",
"unpinSidebar": "取消固定側邊欄",
"hideOnThisPage": "隱藏此頁面側邊欄",
"showSidebar": "顯示側邊欄",
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
"switchToListView": "切換至列表檢視",
"switchToTreeView": "切換到樹狀檢視",
"recursiveOn": "包含子資料夾",
@@ -981,6 +1016,18 @@
"storage": "儲存空間",
"insights": "洞察"
},
"metrics": {
"totalModels": "模型總數",
"totalStorage": "總儲存空間",
"totalGenerations": "總生成次數",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "唯一標籤",
"unusedModels": "未使用模型",
"avgUsesPerModel": "平均使用次數/模型"
},
"usage": {
"mostUsedLoras": "最常用的 LoRA",
"mostUsedCheckpoints": "最常用的 Checkpoint",
@@ -998,13 +1045,77 @@
},
"insights": {
"smartInsights": "智慧洞察",
"recommendations": "推薦"
"recommendations": "推薦",
"noInsights": "暫無可用洞察",
"unusedLoras": {
"high": {
"title": "大量未使用的 LoRA",
"description": "你的 LoRA 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "考慮整理或封存未使用的模型以釋放儲存空間。"
}
},
"unusedCheckpoints": {
"detected": {
"title": "檢測到未使用的 Checkpoint",
"description": "你的 Checkpoint 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "審查並考慮刪除不再需要的 Checkpoint。"
}
},
"unusedEmbeddings": {
"high": {
"title": "大量未使用的 Embedding",
"description": "你的 Embedding 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "考慮整理或封存未使用的 Embedding 以優化你的收藏。"
}
},
"collection": {
"large": {
"title": "檢測到大型收藏",
"description": "你的模型收藏正在使用 {size} 的儲存空間。",
"suggestion": "考慮使用外部儲存或雲端解決方案以獲得更好的組織。"
}
},
"activity": {
"active": {
"title": "活躍用戶",
"description": "你已經完成了 {count} 次生成!",
"suggestion": "繼續探索並用你的模型創作精彩內容。"
}
}
},
"charts": {
"collectionOverview": "收藏總覽",
"baseModelDistribution": "基礎模型分布",
"usageTrends": "使用趨勢(最近 30 天)",
"usageDistribution": "使用分布"
"usageDistribution": "使用分布",
"date": "日期",
"usageCount": "使用次數",
"fileSizeBytes": "檔案大小(位元組)",
"models": "模型",
"loraUsage": "LoRA 使用量",
"checkpointUsage": "Checkpoint 使用量",
"embeddingUsage": "Embedding 使用量"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "擴散模型",
"embedding": "Embedding"
},
"placeholders": {
"loading": "載入中...",
"noModels": "找不到模型",
"errorLoading": "資料載入失敗",
"noStorageData": "暫無儲存資料",
"rootFolder": "根目錄",
"chartLibraryMissing": "需要 Chart.js 函式庫來顯示圖表"
},
"tooltips": {
"tagCount": "{tag}{count} 個模型",
"chartUsage": "{name}{size}{count} 次使用",
"chartPercentage": "{label}{value}{pct}%"
}
},
"modals": {
@@ -1014,9 +1125,9 @@
"download": {
"title": "從網址下載模型",
"titleWithType": "從網址下載 {type}",
"url": "Civitai 網址",
"civitaiUrl": "Civitai 網址:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "每行輸入一個 CivitAI 或 CivArchive URL。支援批量下載多個 URL。",
"locationPreview": "下載位置預覽",
"useDefaultPath": "使用預設路徑",
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
@@ -1225,7 +1336,9 @@
},
"notes": {
"saved": "備註已儲存",
"saveFailed": "儲存備註失敗"
"saveFailed": "儲存備註失敗",
"showMore": "展開",
"showLess": "收起"
},
"usageTips": {
"addPresetParameter": "新增預設參數...",
@@ -1378,6 +1491,21 @@
"versionDeleted": "已刪除此版本"
}
}
},
"metadataFetchSummary": {
"title": "元資料獲取摘要",
"statSuccess": "成功",
"statFailed": "失敗",
"statSkipped": "已跳過",
"statTotal": "總計掃描",
"statDuration": "耗時",
"successMessage": "全部 {count} 個 {type} 更新成功!",
"failedItems": "失敗項目 ({count})",
"close": "關閉",
"copyReport": "複製報告",
"downloadCsv": "下載 CSV",
"columnModelName": "模型名稱",
"columnError": "錯誤"
}
},
"modelTags": {
@@ -1391,15 +1519,6 @@
"duplicate": "此標籤已存在"
}
},
"keyboard": {
"navigation": "鍵盤導覽:",
"shortcuts": {
"pageUp": "向上捲動一頁",
"pageDown": "向下捲動一頁",
"home": "跳至頂部",
"end": "跳至底部"
}
},
"initialization": {
"title": "初始化",
"message": "正在準備您的工作區...",
@@ -1487,11 +1606,14 @@
"noMatchingNodes": "目前工作流程中沒有相容的節點",
"noTargetNodeSelected": "未選擇目標節點",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型節點失敗"
"modelFailed": "更新模型節點失敗",
"embeddingAdded": "Embedding 已附加到工作流",
"embeddingFailed": "傳送 Embedding 到工作流失敗"
},
"nodeSelector": {
"recipe": "配方",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "取代",
"append": "附加",
"selectTargetNode": "選擇目標節點",
@@ -1713,6 +1835,10 @@
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
"repairBulkSkipped": "所選 {total} 個配方無需修復",
"repairBulkFailed": "修復所選配方失敗:{message}",
"reimporting": "正在從來源重新匯入配方...",
"reimportSuccess": "配方已從來源重新匯入成功",
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
"reimportBulkFailed": "重新匯入某些配方失敗",
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
},
@@ -1930,7 +2056,9 @@
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "已複製到剪貼簿",
"downloadStarted": "下載已開始"
}
},
"doctor": {

View File

@@ -33,6 +33,7 @@ from .utils.example_images_migration import ExampleImagesMigration
from .services.websocket_manager import ws_manager
from .services.example_images_cleanup_service import ExampleImagesCleanupService
from .middleware.csp_middleware import relax_csp_for_remote_media
from .middleware.error_middleware import api_json_error
logger = logging.getLogger(__name__)
@@ -76,6 +77,11 @@ class LoraManager:
"""Initialize and register all routes using the new refactored architecture"""
app = PromptServer.instance.app
# Register JSON error middleware for /api/* routes as the outermost
# middleware so it catches errors from all other middlewares.
if api_json_error not in app.middlewares:
app.middlewares.insert(0, api_json_error)
if relax_csp_for_remote_media not in app.middlewares:
# Ensure CSP relaxer executes after ComfyUI's block_external_middleware so it can
# see and extend the restrictive header instead of being overwritten by it.
@@ -189,6 +195,10 @@ class LoraManager:
# Register DownloadManager with ServiceRegistry
await ServiceRegistry.get_download_manager()
# Initialize DownloadQueueService for persistent queue/history
await ServiceRegistry.get_download_queue_service()
await ServiceRegistry.get_backup_service()
from .services.metadata_service import initialize_metadata_providers
@@ -426,5 +436,14 @@ class LoraManager:
try:
logger.info("LoRA Manager: Cleaning up services")
# Cancel any in-flight scanner initialization tasks so thread-pool
# workers (e.g. _initialize_cache_sync) can break out of their loops
# when the server shuts down (e.g. Ctrl+C on WSL).
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
scanner = ServiceRegistry.get_service_sync(name)
if scanner is not None and hasattr(scanner, "cancel_task"):
scanner.cancel_task()
logger.debug("LoRA Manager: Cancelled %s", name)
except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True)

View File

@@ -5,9 +5,10 @@ MODELS = "models"
PROMPTS = "prompts"
SAMPLING = "sampling"
LORAS = "loras"
EMBEDDINGS = "embeddings"
SIZE = "size"
IMAGES = "images"
IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes
# 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

@@ -901,6 +901,55 @@ class LoraLoaderManagerExtractor(NodeMetadataExtractor):
"node_id": node_id
}
class LoraTextLoaderManagerExtractor(NodeMetadataExtractor):
"""Extract LoRA metadata from LoraTextLoaderLM (LoRA Text Loader).
The node accepts a `lora_syntax` STRING containing <lora:name:strength> tags
(same format as the ComfyUI prompt), plus an optional `lora_stack`.
This extractor parses the syntax string using the same regex as the node.
"""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
active_loras = []
# Process lora_stack if available (optional input)
if "lora_stack" in inputs:
lora_stack = inputs.get("lora_stack", [])
for item in lora_stack:
# lora_stack entries are (path, model_strength, clip_strength) tuples
if isinstance(item, (list, tuple)) and len(item) >= 2:
lora_path = item[0]
model_strength = item[1]
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
active_loras.append({
"name": lora_name,
"strength": round(float(model_strength), 2)
})
# Process lora_syntax string input
if "lora_syntax" in inputs:
lora_syntax = inputs.get("lora_syntax", "")
if lora_syntax and isinstance(lora_syntax, str):
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
matches = re.findall(pattern, lora_syntax, re.IGNORECASE)
for match in matches:
lora_name = match[0]
model_strength = float(match[1])
active_loras.append({
"name": lora_name,
"strength": round(model_strength, 2)
})
if active_loras:
metadata[LORAS][node_id] = {
"lora_list": active_loras,
"node_id": node_id
}
class FluxGuidanceExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
@@ -1146,6 +1195,7 @@ NODE_EXTRACTORS = {
"UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager
"LoraLoader": LoraLoaderExtractor,
"LoraLoaderLM": LoraLoaderManagerExtractor,
"LoraTextLoaderLM": LoraTextLoaderManagerExtractor,
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
"TensorRTLoader": TensorRTLoaderExtractor,
# Conditioning

View File

@@ -16,6 +16,8 @@ IMG_EXTENSIONS = (
".tif",
".tiff",
".webp",
".avif",
".jxl",
".mp4"
)

View File

@@ -0,0 +1,71 @@
"""JSON error middleware for API routes.
Ensures all responses to /api/* requests return valid JSON that the
browser-extension frontend can JSON.parse() without crashing, even when
the route does not exist (404) or the handler raises an exception (500).
Extension consumers call response.json() unconditionally — an HTML error
page causes ``SyntaxError: unexpected end of data`` that leaks into the
popup UI as a toast notification.
"""
from __future__ import annotations
import logging
from typing import Awaitable, Callable
from aiohttp import web
logger = logging.getLogger(__name__)
@web.middleware
async def api_json_error(
request: web.Request,
handler: Callable[[web.Request], Awaitable[web.Response]],
) -> web.Response:
"""Return JSON ``{"success": false, "error": "..."}`` for API errors.
Only intercepts paths starting with ``/api/`` — all other routes
(frontend pages, static files, WebSocket upgrades) pass through
unchanged.
"""
if not request.path.startswith("/api/"):
return await handler(request)
try:
response = await handler(request)
return response
except web.HTTPException as exc:
# Let redirects (301, 302, 307, 308) propagate — they are not errors.
if exc.status < 400:
raise
logger.warning(
"API %s %s returned HTTP %d: %s",
request.method,
request.path,
exc.status,
exc.reason,
)
return web.json_response(
{"success": False, "error": f"{exc.status}: {exc.reason}"},
status=exc.status,
)
except Exception as exc:
logger.error(
"API %s %s raised unhandled exception: %s",
request.method,
request.path,
exc,
exc_info=True,
)
return web.json_response(
{
"success": False,
"error": f"500: Internal Server Error ({type(exc).__name__})",
},
status=500,
)

View File

@@ -11,7 +11,7 @@ from ..metadata_collector.metadata_processor import MetadataProcessor
from ..metadata_collector import get_metadata
from ..utils.constants import CARD_PREVIEW_WIDTH
from ..utils.exif_utils import ExifUtils
from ..utils.utils import calculate_recipe_fingerprint
from ..utils.utils import calculate_recipe_fingerprint, sanitize_folder_name
from PIL import Image, PngImagePlugin
import piexif
import logging
@@ -298,7 +298,12 @@ class SaveImageLM:
key = parts[0]
if key == "seed" and "seed" in metadata_dict:
filename = filename.replace(segment, str(metadata_dict.get("seed", "")))
seed_value = metadata_dict.get("seed")
if seed_value is not None:
filename = filename.replace(segment, str(seed_value))
else:
# Fallback if seed was not captured by metadata collector
filename = filename.replace(segment, "0")
elif key == "width" and "size" in metadata_dict:
size = metadata_dict.get("size", "x")
w = size.split("x")[0] if isinstance(size, str) else size[0]
@@ -309,12 +314,14 @@ class SaveImageLM:
filename = filename.replace(segment, str(h))
elif key == "pprompt" and "prompt" in metadata_dict:
prompt = metadata_dict.get("prompt", "").replace("\n", " ")
prompt = sanitize_folder_name(prompt)
if len(parts) >= 2:
length = int(parts[1])
prompt = prompt[:length]
filename = filename.replace(segment, prompt.strip())
elif key == "nprompt" and "negative_prompt" in metadata_dict:
prompt = metadata_dict.get("negative_prompt", "").replace("\n", " ")
prompt = sanitize_folder_name(prompt)
if len(parts) >= 2:
length = int(parts[1])
prompt = prompt[:length]
@@ -328,6 +335,7 @@ class SaveImageLM:
model = "model_unavailable"
else:
model = os.path.splitext(os.path.basename(model_value))[0]
model = sanitize_folder_name(model)
if len(parts) >= 2:
length = int(parts[1])
model = model[:length]

View File

@@ -49,7 +49,10 @@ from ...utils.constants import (
VALID_LORA_TYPES,
)
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import is_valid_example_images_root
from ...utils.example_images_paths import (
find_non_compliant_items_in_example_images_root,
is_valid_example_images_root,
)
from ...utils.lora_metadata import extract_trained_words
from ...utils.session_logging import get_standalone_session_log_snapshot
from ...utils.usage_stats import UsageStats
@@ -1328,6 +1331,9 @@ class SettingsHandler:
"folder_paths",
"libraries",
"active_library",
# Sensitive — never expose the actual value to the frontend;
# frontend receives a boolean instead (civitai_api_key_set).
"civitai_api_key",
}
)
@@ -1382,6 +1388,9 @@ class SettingsHandler:
value = self._settings.get(key)
if value is not None:
response_data[key] = value
# Sensitive fields: only expose a boolean indicating whether set
raw_key = self._settings.get("civitai_api_key")
response_data["civitai_api_key_set"] = bool(raw_key)
settings_file = getattr(self._settings, "settings_file", None)
if settings_file:
response_data["settings_file"] = settings_file
@@ -1492,6 +1501,16 @@ class SettingsHandler:
if not os.path.isdir(folder_path):
return "Please set a dedicated folder for example images."
if not self._is_dedicated_example_images_folder(folder_path):
offending = find_non_compliant_items_in_example_images_root(folder_path)
if offending:
items_str = ", ".join(repr(item) for item in offending[:5])
if len(offending) > 5:
items_str += f" … and {len(offending) - 5} more"
return (
f"The folder contains items that are not valid example image "
f"folders: {items_str}. Please use a dedicated, empty folder "
f"for example images to prevent accidental data loss."
)
return "Please set a dedicated folder for example images."
return None
@@ -3086,6 +3105,7 @@ class NodeRegistryHandler:
data = await request.json()
widget_name = data.get("widget_name")
value = data.get("value")
mode = data.get("mode", "replace")
node_ids = data.get("node_ids")
if not isinstance(widget_name, str) or not widget_name:
@@ -3133,6 +3153,7 @@ class NodeRegistryHandler:
"id": parsed_node_id,
"widget_name": widget_name,
"value": value,
"mode": mode,
}
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_progress_callback import WebSocketProgressCallback
from ...services.download_queue_service import DownloadQueueService
from ...services.errors import RateLimitError, ResourceNotFoundError
from ...utils.civitai_utils import resolve_license_payload
from ...utils.file_utils import calculate_sha256
@@ -1271,6 +1272,14 @@ class ModelQueryHandler:
license_flags = (model_data or {}).get("license_flags")
if license_flags is not None:
response_payload["license_flags"] = int(license_flags)
# Include the user's license icon style preference so the
# ComfyUI tooltip can pick the right set without a separate
# API call.
try:
settings = get_settings_manager()
response_payload["use_new_license_icons"] = settings.get("use_new_license_icons", True)
except Exception:
pass
return web.json_response(response_payload)
return web.json_response(
{
@@ -1567,6 +1576,291 @@ class ModelDownloadHandler:
)
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
completed_at_raw = request.query.get("completed_at")
completed_at = float(completed_at_raw) if completed_at_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,
completed_at=completed_at,
)
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)
async def update_download_queue_status(self, request: web.Request) -> web.Response:
"""Update the status of a queue item (non-terminal transitions).
Supported transitions include ``queued → downloading``,
``downloading → paused``, ``paused → downloading``, etc.
Terminal transitions (``completed``, ``failed``, ``canceled``)
should use ``complete_download_in_queue`` instead.
"""
try:
download_id = request.query.get("download_id")
status = request.query.get("status")
if not download_id or not status:
return web.json_response(
{
"success": False,
"error": "download_id and status are required",
},
status=400,
)
service = await DownloadQueueService.get_instance()
updated = await service.update_status(download_id, status)
if not updated:
return web.json_response(
{"success": False, "error": "Download not found in queue"},
status=404,
)
return web.json_response({"success": True})
except Exception as exc:
self._logger.error(
"Error updating download queue status: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelCivitaiHandler:
"""CivitAI integration endpoints."""
@@ -1608,7 +1902,9 @@ class ModelCivitaiHandler:
return web.json_response(result)
except Exception as exc:
self._logger.error(
"Error in fetch_all_civitai for %ss: %s", self._service.model_type, exc
"Error in fetch_all_civitai for %ss: %s",
self._service.model_type, exc,
exc_info=True,
)
return web.Response(text=str(exc), status=500)
@@ -2016,10 +2312,21 @@ class ModelUpdateHandler:
self._logger.error("Failed to refresh model updates: %s", exc, exc_info=True)
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 = []
for record in records.values():
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))
return web.json_response(
@@ -2585,6 +2892,20 @@ class ModelHandlerSet:
"pause_download_get": self.download.pause_download_get,
"resume_download_get": self.download.resume_download_get,
"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,
"update_download_queue_status": self.download.update_download_queue_status,
"get_civitai_versions": self.civitai.get_civitai_versions,
"get_civitai_model_by_version": self.civitai.get_civitai_model_by_version,
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,

View File

@@ -13,7 +13,7 @@ from ...config import config as global_config
logger = logging.getLogger(__name__)
_CHUNK_SIZE = 256 * 1024 # 256 KB
_CHUNK_SIZE = 1024 * 1024 # 1 MB — balance between streaming iteration overhead and per-chunk memory
# Video file extensions that bypass native sendfile on Windows
# to avoid IOCP/ProactorEventLoop crashes during client disconnect.
@@ -55,16 +55,19 @@ class PreviewHandler:
logger.debug("Preview file not found at %s", str(resolved))
raise web.HTTPNotFound(text="Preview file not found")
# Video files: stream manually to avoid Windows native sendfile crash.
# aiohttp's FileResponse uses _sendfile_native on Windows (IOCP-based),
# which breaks when the client disconnects mid-transfer — this happens
# constantly when users scroll through a gallery of animated previews.
suffix = resolved.suffix.lower()
if suffix in _VIDEO_EXTENSIONS:
return await self._stream_file(request, resolved)
# aiohttp's FileResponse handles range requests and content headers for us.
return web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
# aiohttp's FileResponse handles range requests, content headers, and
# uses kernel sendfile (zero-copy DMA) on Linux/macOS. On Windows it
# uses IOCP-based _sendfile_native which can crash when the client
# disconnects mid-transfer during fast scrolling. The _stream_file()
# fallback is kept for a future compat toggle.
#
# Set explicit Cache-Control so the browser can cache video (and image)
# previews across VirtualScroller recycling cycles. Without this,
# Chrome does not cache 206 Partial Content responses for <video>
# elements, causing the same video to be re-downloaded on every scroll.
resp = web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
resp.headers["Cache-Control"] = "public, max-age=86400"
return resp
async def _stream_file(
self, request: web.Request, path: Path
@@ -83,6 +86,10 @@ class PreviewHandler:
resp.content_type = content_type
resp.content_length = file_size
# Allow browser caching: video previews rarely change during a session.
# The frontend already appends ?t={version} to bust cache on update.
resp.headers["Cache-Control"] = "public, max-age=86400"
await resp.prepare(request)
try:

View File

@@ -102,6 +102,7 @@ class RecipeHandlerSet:
"check_image_exists": self.management.check_image_exists,
"import_from_url": self.management.import_from_url,
"create_from_example": self.management.create_from_example,
"reimport_recipe": self.management.reimport_recipe,
}
@@ -799,6 +800,126 @@ class RecipeManagementHandler:
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def reimport_recipe(self, request: web.Request) -> web.Response:
"""Delete a recipe and re-import it from its source URL.
This gives the recipe a fresh start — re-downloads the image from
CivitAI, re-parses EXIF metadata with the current parser, and
re-resolves LoRAs / checkpoint. User edits (title, tags, favorite)
are carried over from the old recipe.
"""
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
recipe_id = request.match_info["recipe_id"]
old_recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
if not old_recipe:
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
source_path = old_recipe.get("source_path")
if not source_path:
return web.json_response(
{
"success": False,
"error": (
"Recipe has no source URL — cannot re-import. "
"Use repair or manual import instead."
),
},
status=400,
)
user_edits: dict[str, Any] = {}
for key in ("title", "tags", "favorite", "preview_nsfw_level"):
if key in old_recipe and old_recipe[key] is not None:
user_edits[key] = old_recipe[key]
if "tags" in user_edits and not isinstance(user_edits["tags"], list):
del user_edits["tags"]
old_file_path = old_recipe.get("file_path", "")
old_folder = os.path.dirname(old_file_path) if old_file_path else None
image_id = extract_civitai_image_id(source_path)
is_local_file = not image_id and os.path.isfile(source_path)
if not image_id and not is_local_file:
return web.json_response(
{
"success": False,
"error": (
"Recipe source is neither a valid CivitAI image URL "
"nor an accessible local file. "
"Use repair or manual import instead."
),
},
status=400,
)
if is_local_file:
return await self._do_reimport_from_local(
source_path,
recipe_scanner,
recipe_id=recipe_id,
target_dir=old_folder,
user_edits=user_edits,
old_title=old_recipe.get("title", ""),
)
async with self._import_semaphore:
import_response = await self._do_import_from_url(
source_path,
recipe_scanner,
target_dir=old_folder,
)
await self._persistence_service.delete_recipe(
recipe_scanner=recipe_scanner, recipe_id=recipe_id
)
body_bytes = import_response.body
if not body_bytes:
raise RuntimeError("Re-import returned an empty response")
import_body = json.loads(body_bytes.decode())
new_recipe_id = import_body.get("recipe_id")
if new_recipe_id and user_edits:
try:
await self._persistence_service.update_recipe(
recipe_scanner=recipe_scanner,
recipe_id=new_recipe_id,
updates=user_edits,
)
except Exception as exc:
self._logger.warning(
"Re-import succeeded but failed to carry over "
"user edits for new recipe %s: %s",
new_recipe_id,
exc,
)
return web.json_response(
{
"success": True,
"old_recipe_id": recipe_id,
"recipe_id": new_recipe_id,
"source_path": source_path,
}
)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
except RecipeValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except RecipeDownloadError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
self._logger.error(
"Error reimporting recipe: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_repair_progress(self, request: web.Request) -> web.Response:
try:
progress = self._ws_manager.get_recipe_repair_progress()
@@ -907,6 +1028,7 @@ class RecipeManagementHandler:
extension,
civitai_meta_raw,
model_version_id,
_original_image_url,
) = await self._download_remote_media(image_url)
# Extract embedded EXIF metadata (offloaded to thread pool in this call)
@@ -1319,7 +1441,9 @@ class RecipeManagementHandler:
"exclude": False,
}
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any, Any]:
async def _download_remote_media(
self, image_url: str
) -> tuple[bytes, str, Any, Any, Optional[str]]:
civitai_client = self._civitai_client_getter()
downloader = await self._downloader_factory()
temp_path = None
@@ -1394,11 +1518,16 @@ class RecipeManagementHandler:
if mvids and isinstance(civitai_meta_raw, dict):
civitai_meta_raw["modelVersionIds"] = mvids
original_url = (
image_info.get("url") if civitai_image_id and image_info else None
)
return (
file_obj.read(),
extension,
civitai_meta_raw,
model_ver_id,
original_url,
)
except RecipeDownloadError:
raise
@@ -1468,15 +1597,8 @@ class RecipeManagementHandler:
cache = await recipe_scanner.get_cached_data()
# Build lookup: image_id -> recipe_id from stored source_path
image_to_recipe = {}
for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path")
if not source:
continue
image_id = extract_civitai_image_id(source)
if image_id and image_id not in image_to_recipe:
image_to_recipe[image_id] = recipe.get("id")
# Use precomputed image_id_map (built once at cache init)
image_to_recipe = getattr(cache, "image_id_map", {})
results = {}
for img_id in requested_ids:
@@ -1512,18 +1634,20 @@ class RecipeManagementHandler:
"Could not extract Civitai image ID from URL"
)
# Check for duplicate (fast, before acquiring semaphore), unless force
if not force:
cache = await recipe_scanner.get_cached_data()
image_to_recipe = getattr(cache, "image_id_map", {})
existing_recipe_id = image_to_recipe.get(image_id)
if existing_recipe_id:
recipe_name = ""
for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path")
if source:
existing_id = extract_civitai_image_id(source)
if existing_id == image_id:
if str(recipe.get("id", "")) == existing_recipe_id:
recipe_name = recipe.get("title", "") or ""
break
return web.json_response({
"success": True,
"recipe_id": recipe.get("id"),
"name": recipe.get("title", ""),
"recipe_id": existing_recipe_id,
"name": recipe_name,
"already_exists": True,
})
@@ -1543,6 +1667,9 @@ class RecipeManagementHandler:
self,
image_url: str,
recipe_scanner: Any,
*,
recipe_id: str | None = None,
target_dir: str | None = None,
) -> web.Response:
image_id = extract_civitai_image_id(image_url)
if not image_id:
@@ -1550,7 +1677,7 @@ class RecipeManagementHandler:
"Could not extract Civitai image ID from URL"
)
image_bytes, extension, civitai_meta_raw, model_version_id = (
image_bytes, extension, civitai_meta_raw, model_version_id, original_image_url = (
await self._download_remote_media(image_url)
)
@@ -1588,6 +1715,51 @@ class RecipeManagementHandler:
"Failed to extract embedded metadata: %s", exc
)
if not parsed_embedded and original_image_url:
self._logger.debug(
"Optimized image has no embedded metadata, "
"falling back to original: %s",
original_image_url,
)
try:
downloader = await self._downloader_factory()
with tempfile.NamedTemporaryFile(
suffix=".png", delete=False
) as tmp:
orig_tmp_path = tmp.name
try:
success, _ = await downloader.download_file(
original_image_url, orig_tmp_path, use_auth=False
)
if success:
raw_orig = await asyncio.to_thread(
ExifUtils.extract_image_metadata, orig_tmp_path
)
if raw_orig:
parser = (
self._analysis_service._recipe_parser_factory.create_parser(
raw_orig
)
)
if parser:
parsed_embedded = await parser.parse_metadata(
raw_orig, recipe_scanner=recipe_scanner
)
if (
parsed_embedded
and "gen_params" in parsed_embedded
):
embedded_gen_params = parsed_embedded[
"gen_params"
]
finally:
if os.path.exists(orig_tmp_path):
os.unlink(orig_tmp_path)
except Exception as exc:
self._logger.warning(
"Failed to extract metadata from original image: %s", exc
)
# Parse CivitAI API meta to discover all resources from modelVersionIds.
# Run unconditionally — EXIF parsing succeeds for gen_params but misses
# LoRAs (modelVersionIds is NOT in the image EXIF).
@@ -1671,9 +1843,104 @@ class RecipeManagementHandler:
tags=[],
metadata=metadata,
extension=extension,
recipe_id=recipe_id,
target_dir=target_dir,
)
return web.json_response(result.payload, status=result.status)
async def _do_reimport_from_local(
self,
file_path: str,
recipe_scanner: Any,
*,
recipe_id: str,
target_dir: str | None,
user_edits: dict[str, Any],
old_title: str,
) -> web.Response:
"""Re-import a recipe from a local image file.
Reads the original source file, re-parses its EXIF metadata, saves a
fresh recipe, then deletes the old one.
"""
normalized = os.path.normpath(file_path)
if not os.path.isfile(normalized):
raise RecipeNotFoundError(
f"Source file no longer accessible: {normalized}"
)
with open(normalized, "rb") as fh:
image_bytes = fh.read()
extension = os.path.splitext(normalized)[1].lower() or ".png"
analysis_result = await self._analysis_service.analyze_local_image(
file_path=normalized,
recipe_scanner=recipe_scanner,
)
analysis_payload: dict[str, Any] = analysis_result.payload
gen_params = analysis_payload.get("gen_params") or {}
loras = analysis_payload.get("loras") or []
checkpoint = analysis_payload.get("checkpoint")
base_model = analysis_payload.get("base_model", "")
metadata: dict[str, Any] = {
"base_model": base_model,
"loras": loras,
"gen_params": gen_params,
"source_path": normalized,
}
if checkpoint:
metadata["checkpoint"] = checkpoint
prompt = (
gen_params.get("prompt")
or gen_params.get("positivePrompt")
or ""
)
name = " ".join(str(prompt).split()[:10]) if prompt else old_title
result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner,
image_bytes=image_bytes,
image_base64=analysis_payload.get("image_base64"),
name=name,
tags=[],
metadata=metadata,
extension=extension,
target_dir=target_dir,
)
await self._persistence_service.delete_recipe(
recipe_scanner=recipe_scanner, recipe_id=recipe_id
)
new_recipe_id = result.payload.get("recipe_id")
if new_recipe_id and user_edits:
try:
await self._persistence_service.update_recipe(
recipe_scanner=recipe_scanner,
recipe_id=new_recipe_id,
updates=user_edits,
)
except Exception as exc:
self._logger.warning(
"Re-import (local) succeeded but failed to carry over "
"user edits for recipe %s: %s",
new_recipe_id,
exc,
)
return web.json_response(
{
"success": True,
"old_recipe_id": recipe_id,
"recipe_id": new_recipe_id,
"source_path": normalized,
}
)
async def create_from_example(self, request: web.Request) -> web.Response:
"""Create a recipe from a model's example image using cached metadata.

View File

@@ -107,6 +107,40 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"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(
"GET", "/api/lm/downloads/queue/status", "update_download_queue_status"
),
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
RouteDefinition("GET", "/{prefix}", "handle_models_page"),
)

View File

@@ -78,6 +78,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"POST", "/api/lm/recipes/create-from-example", "create_from_example"
),
RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
),
)

View File

@@ -11,6 +11,8 @@ from ..config import config
from ..services.settings_manager import get_settings_manager
from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry
from ..services.model_query import normalize_sub_type, resolve_sub_type
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
from ..utils.usage_stats import UsageStats
logger = logging.getLogger(__name__)
@@ -140,6 +142,21 @@ class StatsRoutes:
# Get usage statistics
usage_data = await self.usage_stats.get_stats()
# CivitAI model type distribution across all model types
# Use the same logic as the filter panel: normalize_sub_type(resolve_sub_type(entry))
# with sub-type validation per model type
model_types_counter: Counter[str] = Counter()
for entry in lora_cache.raw_data:
ntype = normalize_sub_type(resolve_sub_type(entry))
if ntype and ntype in VALID_LORA_SUB_TYPES:
model_types_counter[ntype] += 1
for entry in checkpoint_cache.raw_data:
ntype = normalize_sub_type(resolve_sub_type(entry))
if ntype and ntype in VALID_CHECKPOINT_SUB_TYPES:
model_types_counter[ntype] += 1
# Embeddings: always count as "embedding" regardless of CivitAI sub-type
model_types_counter['embedding'] = len(embedding_cache.raw_data)
return web.json_response({
'success': True,
'data': {
@@ -154,7 +171,8 @@ class StatsRoutes:
'total_generations': usage_data.get('total_executions', 0),
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})),
'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {})),
'unused_embeddings': self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {}))
'unused_embeddings': self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {})),
'model_types_distribution': dict(model_types_counter.most_common())
}
})
@@ -459,9 +477,12 @@ class StatsRoutes:
if unused_lora_percent > 50:
insights.append({
'type': 'warning',
'title': 'High Number of Unused LoRAs',
'description': f'{unused_lora_percent:.1f}% of your LoRAs ({unused_loras}/{total_loras}) have never been used.',
'suggestion': 'Consider organizing or archiving unused models to free up storage space.'
'key': 'insights.unusedLoras.high',
'params': {
'percent': f'{unused_lora_percent:.1f}',
'count': str(unused_loras),
'total': str(total_loras)
}
})
if total_checkpoints > 0:
@@ -469,9 +490,12 @@ class StatsRoutes:
if unused_checkpoint_percent > 30:
insights.append({
'type': 'warning',
'title': 'Unused Checkpoints Detected',
'description': f'{unused_checkpoint_percent:.1f}% of your checkpoints ({unused_checkpoints}/{total_checkpoints}) have never been used.',
'suggestion': 'Review and consider removing checkpoints you no longer need.'
'key': 'insights.unusedCheckpoints.detected',
'params': {
'percent': f'{unused_checkpoint_percent:.1f}',
'count': str(unused_checkpoints),
'total': str(total_checkpoints)
}
})
if total_embeddings > 0:
@@ -479,9 +503,12 @@ class StatsRoutes:
if unused_embedding_percent > 50:
insights.append({
'type': 'warning',
'title': 'High Number of Unused Embeddings',
'description': f'{unused_embedding_percent:.1f}% of your embeddings ({unused_embeddings}/{total_embeddings}) have never been used.',
'suggestion': 'Consider organizing or archiving unused embeddings to optimize your collection.'
'key': 'insights.unusedEmbeddings.high',
'params': {
'percent': f'{unused_embedding_percent:.1f}',
'count': str(unused_embeddings),
'total': str(total_embeddings)
}
})
# Storage insights
@@ -492,18 +519,20 @@ class StatsRoutes:
if total_size > 100 * 1024 * 1024 * 1024: # 100GB
insights.append({
'type': 'info',
'title': 'Large Collection Detected',
'description': f'Your model collection is using {self._format_size(total_size)} of storage.',
'suggestion': 'Consider using external storage or cloud solutions for better organization.'
'key': 'insights.collection.large',
'params': {
'size': self._format_size(total_size)
}
})
# Recent activity insight
if usage_data.get('total_executions', 0) > 100:
insights.append({
'type': 'success',
'title': 'Active User',
'description': f'You\'ve completed {usage_data["total_executions"]} generations so far!',
'suggestion': 'Keep exploring and creating amazing content with your models.'
'key': 'insights.activity.active',
'params': {
'count': str(usage_data['total_executions'])
}
})
return web.json_response({

View File

@@ -1,7 +1,6 @@
import os
import logging
import toml
import git
import zipfile
import shutil
import tempfile
@@ -225,7 +224,7 @@ class UpdateRoutes:
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'])
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups', 'stats'])
# Extract ZIP to temp dir
with tempfile.TemporaryDirectory() as tmp_dir:
@@ -235,7 +234,7 @@ class UpdateRoutes:
extracted_root = next(os.scandir(tmp_dir)).path
# Copy files, skipping user data that should be preserved
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups'}
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups', 'stats'}
for item in os.listdir(extracted_root):
if item in skip_items:
continue
@@ -252,7 +251,7 @@ class UpdateRoutes:
# for ComfyUI Manager to work properly
tracking_info_file = os.path.join(plugin_root, '.tracking')
tracking_files = []
skip_tracked = {'civitai', 'wildcards', 'backups'}
skip_tracked = {'civitai', 'wildcards', 'backups', 'stats'}
for root, dirs, files in os.walk(extracted_root):
# Skip user data directories and their contents
rel_root = os.path.relpath(root, extracted_root)
@@ -357,6 +356,15 @@ class UpdateRoutes:
Returns:
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:
# Open the Git repository
repo = git.Repo(plugin_root)
@@ -453,6 +461,7 @@ class UpdateRoutes:
if not os.path.exists(os.path.join(plugin_root, '.git')):
return git_info
import git
repo = git.Repo(plugin_root)
commit = repo.head.commit
git_info['commit_hash'] = commit.hexsha

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
@staticmethod
@@ -348,6 +358,8 @@ class BackupService:
if kind == "model_update":
filename = os.path.basename(archive_member)
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
async def create_auto_snapshot_if_due(self) -> Optional[dict[str, Any]]:

View File

@@ -29,6 +29,7 @@ from .metadata_service import get_default_metadata_provider, get_metadata_provid
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
from .aria2_downloader import Aria2Error, get_aria2_downloader
from .aria2_transfer_state import Aria2TransferStateStore
from .download_queue_service import DownloadQueueService
# Download to temporary file first
import tempfile
@@ -360,6 +361,15 @@ class DownloadManager:
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id)
# Update SQLite queue status to 'downloading'
try:
queue_service = await DownloadQueueService.get_instance()
await queue_service.update_status(task_id, "downloading")
except Exception:
logger.warning(
"Failed to update queue status for %s", task_id, exc_info=True
)
# Use original download implementation
try:
# Check for cancellation before starting
@@ -396,6 +406,22 @@ class DownloadManager:
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id)
# Move queue item to history on completion
try:
queue_service = await DownloadQueueService.get_instance()
await queue_service.complete_download(
download_id=task_id,
status=result.get("status", "completed") if result.get("success") else "failed",
error=result.get("error") if not result.get("success") else None,
file_path=result.get("file_path"),
bytes_downloaded=self._active_downloads.get(task_id, {}).get("bytes_downloaded", 0),
total_bytes=self._active_downloads.get(task_id, {}).get("total_bytes"),
)
except Exception:
logger.warning(
"Failed to complete queue item for %s", task_id, exc_info=True
)
return result
except asyncio.CancelledError:
# Handle cancellation
@@ -404,6 +430,19 @@ class DownloadManager:
self._active_downloads[task_id]["bytes_per_second"] = 0.0
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id)
# Move queue item to history as canceled
try:
queue_service = await DownloadQueueService.get_instance()
await queue_service.complete_download(
download_id=task_id,
status="canceled",
)
except Exception:
logger.warning(
"Failed to cancel queue item for %s", task_id, exc_info=True
)
logger.info(f"Download cancelled for task {task_id}")
raise
except Exception as e:
@@ -417,6 +456,22 @@ class DownloadManager:
self._active_downloads[task_id]["bytes_per_second"] = 0.0
if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id)
# Move queue item to history as failed
try:
queue_service = await DownloadQueueService.get_instance()
await queue_service.complete_download(
download_id=task_id,
status="failed",
error=str(e),
bytes_downloaded=self._active_downloads.get(task_id, {}).get("bytes_downloaded", 0),
total_bytes=self._active_downloads.get(task_id, {}).get("total_bytes"),
)
except Exception:
logger.warning(
"Failed to complete queue item for %s", task_id, exc_info=True
)
return {"success": False, "error": str(e)}
finally:
# Schedule cleanup of download record after delay

View File

@@ -0,0 +1,871 @@
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()
await cls._instance.deduplicate()
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,
completed_at: Optional[float] = 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``).
When *completed_at* is provided it is used as the completion
timestamp; otherwise ``time.time()`` is used.
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 = completed_at if completed_at is not None else 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, and the original
history entry is **deleted** to prevent exponential growth when
the retried item is later canceled or fails again and re-retried.
"""
async with self._lock:
conn = self._get_conn()
row = conn.execute(
"SELECT * FROM download_history WHERE id = ?",
(item_id,),
).fetchone()
if 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.execute(
"DELETE FROM download_history WHERE id = ?",
(item_id,),
)
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.
Each history entry is **deleted** after being re-queued so that
repeated retry-all calls do not cause exponential growth.
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,
),
)
conn.execute(
"DELETE FROM download_history WHERE id = ?",
(row["id"],),
)
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),
}
# ------------------------------------------------------------------
# Deduplication (one-time cleanup for bug #980)
# ------------------------------------------------------------------
async def deduplicate(self) -> dict[str, int]:
"""Remove duplicate entries caused by the retry-amplification bug.
The bug (issue #980) caused the same download to appear N times in
both the queue and history tables when ``retry_all_failed`` was
called repeatedly without deleting the original history rows.
This method is called **once** when the singleton is first created.
It is idempotent — after the first run there will be no duplicates
to remove, so subsequent calls are a no-op.
Returns a dict with the count of removed rows per table.
"""
result: dict[str, int] = {
"removed_history": 0,
"removed_queue": 0,
"removed_orphan_queue": 0,
}
async with self._lock:
conn = self._get_conn()
# 1. History: for each (model_id, model_version_id, status) triplet
# keep only the row with the highest id (most recently inserted).
conn.execute("""
DELETE FROM download_history
WHERE id NOT IN (
SELECT MAX(id)
FROM download_history
GROUP BY model_id, model_version_id, status
)
""")
result["removed_history"] = conn.execute(
"SELECT changes()"
).fetchone()[0]
# 2. Cross-status dedup: for each (model_id, model_version_id),
# keep only the entry with the highest-priority terminal status.
# Priority: completed (3) > failed (2) > canceled (1).
# This prevents the same model version from having both a
# 'failed' and a 'canceled' entry (or a 'completed' alongside
# either) after the bug-created duplicates are removed.
conn.execute("""
DELETE FROM download_history
WHERE id NOT IN (
SELECT dh.id
FROM download_history dh
INNER JOIN (
SELECT model_id, model_version_id,
MAX(CASE status
WHEN 'completed' THEN 3
WHEN 'failed' THEN 2
WHEN 'canceled' THEN 1
ELSE 0
END) AS best_prio
FROM download_history
GROUP BY model_id, model_version_id
) best
ON dh.model_id = best.model_id
AND dh.model_version_id = best.model_version_id
AND CASE dh.status
WHEN 'completed' THEN 3
WHEN 'failed' THEN 2
WHEN 'canceled' THEN 1
ELSE 0
END = best.best_prio
GROUP BY dh.model_id, dh.model_version_id
HAVING dh.id = MAX(dh.id)
)
""")
result["removed_history"] += conn.execute(
"SELECT changes()"
).fetchone()[0]
# 3. Queue: for each (model_id, model_version_id) keep only the
# row with the latest added_at (most recently enqueued).
conn.execute("""
DELETE FROM download_queue
WHERE rowid NOT IN (
SELECT MAX(rowid)
FROM download_queue
WHERE status IN ('queued', 'downloading', 'paused', 'waiting')
GROUP BY model_id, model_version_id
)
AND status IN ('queued', 'downloading', 'paused', 'waiting')
""")
result["removed_queue"] = conn.execute(
"SELECT changes()"
).fetchone()[0]
# 4. Remove orphaned queue entries — items that were re-queued
# (source='retry') but whose model version already has a
# terminal history entry. These are artifacts of the buggy
# retry cycle that were never cleaned up.
conn.execute("""
DELETE FROM download_queue
WHERE source = 'retry'
AND (model_id, model_version_id) IN (
SELECT model_id, model_version_id
FROM download_history
WHERE status IN ('failed', 'canceled')
)
AND status IN ('queued', 'waiting')
""")
result["removed_orphan_queue"] = conn.execute(
"SELECT changes()"
).fetchone()[0]
conn.commit()
logger.info(
"Deduplicate: removed %s history rows, %s queue rows, "
"%s orphaned queue rows",
result["removed_history"],
result["removed_queue"],
result["removed_orphan_queue"],
)
return result

View File

@@ -256,7 +256,9 @@ class Downloader:
self._session = None
# Check for app-level proxy settings
proxy_url = None
proxy_url = None # http(s) proxy, passed via the per-request `proxy=` kwarg
socks_proxy_url = None # SOCKS proxy, handled via aiohttp-socks connector
app_proxy_active = False
settings_manager = get_settings_manager()
if settings_manager.get("proxy_enabled", False):
proxy_host = settings_manager.get("proxy_host", "").strip()
@@ -268,9 +270,19 @@ class Downloader:
if proxy_host and proxy_port:
# Build proxy URL
if proxy_username and proxy_password:
proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
full_proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
else:
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
full_proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
app_proxy_active = True
# aiohttp cannot tunnel SOCKS via the per-request `proxy=` kwarg
# (it would send HTTP to the SOCKS port and fail parsing the
# SOCKS handshake reply). SOCKS must be handled by an
# aiohttp-socks ProxyConnector instead.
if proxy_type.startswith("socks"):
socks_proxy_url = full_proxy_url
else:
proxy_url = full_proxy_url
logger.debug(
f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}"
@@ -294,13 +306,27 @@ class Downloader:
logger.debug("SSL: certifi unavailable; using system default CA bundle")
# Optimize TCP connection parameters
connector = aiohttp.TCPConnector(
connector_kwargs = dict(
ssl=ssl_context,
limit=8, # Concurrent connections
ttl_dns_cache=300, # DNS cache timeout
force_close=False, # Keep connections for reuse
enable_cleanup_closed=True,
)
if socks_proxy_url:
# Route all traffic through the SOCKS proxy via aiohttp-socks. The
# connector tunnels every connection, so no per-request `proxy=` is
# used (and must not be — see self._proxy_url below).
try:
from aiohttp_socks import ProxyConnector
except ImportError as e: # pragma: no cover
raise RuntimeError(
"A SOCKS proxy is configured but the 'aiohttp-socks' package "
"is not installed. Install it with: pip install aiohttp-socks"
) from e
connector = ProxyConnector.from_url(socks_proxy_url, **connector_kwargs)
else:
connector = aiohttp.TCPConnector(**connector_kwargs)
# Configure timeout parameters
timeout = aiohttp.ClientTimeout(
@@ -311,12 +337,14 @@ class Downloader:
self._session = aiohttp.ClientSession(
connector=connector,
trust_env=proxy_url
is None, # Only use system proxy if no app-level proxy is set
# Only fall back to system/env proxy when no app-level proxy is active
trust_env=not app_proxy_active,
timeout=timeout,
)
# Store proxy URL for use in requests
# Store proxy URL for per-request use. Stays None for SOCKS because the
# ProxyConnector already tunnels everything; passing proxy= for SOCKS
# would re-trigger the original aiohttp parse error.
self._proxy_url = proxy_url
self._session_created_at = datetime.now()

View File

@@ -216,13 +216,19 @@ class MetadataSyncService:
provider_used: Optional[str] = None
last_error: Optional[str] = None
civitai_api_not_found = False
any_rate_limited = False
for provider_name, provider in provider_attempts:
try:
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
except RateLimitError as exc:
exc.provider = exc.provider or (provider_name or provider.__class__.__name__)
raise
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
provider_name or provider.__class__.__name__,
exc.retry_after or 0,
)
any_rate_limited = True
continue
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
civitai_metadata_candidate, error = None, str(exc)
@@ -258,6 +264,14 @@ class MetadataSyncService:
model_data["last_checked_at"] = datetime.now().timestamp()
needs_save = True
# When the model was already classified as "not on CivitAI" via
# .metadata.json (civitai_deleted=True) but the SQLite cache is
# stale (because the pre-fix code never persisted these flags),
# ensure the flags are written to the scanner cache + SQLite.
if not needs_save and model_data.get("civitai_deleted") is True:
model_data["last_checked_at"] = datetime.now().timestamp()
needs_save = True
# Save metadata if any state was updated
if needs_save:
data_to_save = model_data.copy()
@@ -266,6 +280,7 @@ class MetadataSyncService:
if "last_checked_at" not in data_to_save:
data_to_save["last_checked_at"] = datetime.now().timestamp()
await self._metadata_manager.save_metadata(file_path, data_to_save)
await update_cache_func(file_path, file_path, data_to_save)
default_error = (
"CivitAI model is deleted and metadata archive DB is not enabled"
@@ -276,17 +291,18 @@ class MetadataSyncService:
)
resolved_error = last_error or default_error
if any_rate_limited and "Rate limited" not in resolved_error:
resolved_error = "Rate limited"
if is_expected_offline_error(resolved_error):
resolved_error = OFFLINE_FRIENDLY_MESSAGE
error_msg = (
f"Error fetching metadata: {resolved_error} "
f"(model_name={model_data.get('model_name', '')})"
f"(file={os.path.basename(file_path)}, sha256={sha256})"
)
if is_expected_offline_error(resolved_error):
logger.info(error_msg)
else:
logger.error(error_msg)
# Use case layer (BulkMetadataRefreshUseCase) logs failed models at WARNING level,
# so this level is demoted to DEBUG to avoid duplicate user-visible logging.
logger.debug(error_msg)
return False, error_msg
model_data["from_civitai"] = True
@@ -411,7 +427,18 @@ class MetadataSyncService:
metadata = await metadata_loader(metadata_path)
for key, value in updates.items():
if isinstance(value, dict) and isinstance(metadata.get(key), dict):
if key == "tags" and isinstance(value, list):
# Normalize tags: trim, lowercase, deduplicate
normalized = []
seen = set()
for tag in value:
if isinstance(tag, str):
t = tag.strip().lower()
if t and t not in seen:
normalized.append(t)
seen.add(t)
metadata[key] = normalized
elif isinstance(value, dict) and isinstance(metadata.get(key), dict):
metadata[key].update(value)
else:
metadata[key] = value

View File

@@ -65,7 +65,14 @@ class _RateLimitRetryHelper:
return await func(*args, **kwargs)
except RateLimitError as exc:
attempt += 1
if attempt >= self._retry_limit:
# Determine effective retry limit based on rate-limit magnitude
effective_retry_limit = self._retry_limit # default: 3
if exc.retry_after is not None and exc.retry_after >= 120.0:
# Long rate-limit window (>=2 min) — retries are futile
effective_retry_limit = 1 # total 1 attempt = 0 retries
if attempt >= effective_retry_limit:
exc.provider = exc.provider or label
raise
@@ -81,7 +88,11 @@ class _RateLimitRetryHelper:
def _calculate_delay(self, retry_after: Optional[float], attempt: int) -> float:
if retry_after is not None:
return min(self._max_delay, max(0.0, retry_after))
# Cap at 1800s (30 min) as a safety ceiling. The old 30s cap was
# too low — CivArchive can return retry_after ~1500s, causing all
# retries to fail. A generous ceiling protects against pathological
# server values while still respecting the server's guidance.
return min(1800.0, max(0.0, retry_after))
base_delay = self._base_delay * (2 ** max(0, attempt - 1))
jitter_span = base_delay * self._jitter_ratio
@@ -474,8 +485,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result:
return result, error
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug("Provider %s failed for get_model_by_hash: %s", label, e)
continue
@@ -493,16 +508,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result:
return result
except RateLimitError as exc:
if not_found_confirmed:
logger.debug(
"Suppressing rate limit from %s for model %s: "
"already confirmed as not found by another provider",
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
model_id,
exc.retry_after or 0,
)
return None
exc.provider = exc.provider or label
raise exc
continue
except ResourceNotFoundError:
not_found_confirmed = True
logger.debug(
@@ -528,8 +539,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result:
return result
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug("Provider %s failed for get_model_version: %s", label, e)
continue
@@ -546,8 +561,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result:
return result, error
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug("Provider %s failed for get_model_version_info: %s", label, e)
continue
@@ -568,8 +587,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
except NotImplementedError:
continue
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug(
"Provider %s failed for get_model_versions_by_hashes: %s",
@@ -590,8 +613,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result is not None:
return result
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug("Provider %s failed for get_user_models: %s", label, e)
continue

View File

@@ -294,12 +294,14 @@ class ModelFilterSet:
for tag, state in tag_filters.items():
if not tag:
continue
# Normalize to lowercase for case-insensitive matching
normalized = tag.strip().lower()
if state == "exclude":
exclude_tags.add(tag)
exclude_tags.add(normalized)
else:
include_tags.add(tag)
include_tags.add(normalized)
else:
include_tags = {tag for tag in tag_filters if tag}
include_tags = {tag.strip().lower() for tag in tag_filters if tag}
if include_tags:
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
@@ -318,13 +320,17 @@ class ModelFilterSet:
return True
# Otherwise, check if all non-special tags match
if non_special_tags:
return all(tag in (item_tags or []) for tag in non_special_tags)
# Case-insensitive: normalize item tags too
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
return all(tag in normalized_item_tags for tag in non_special_tags)
return True
# Normal case: all tags must match
return all(tag in (item_tags or []) for tag in non_special_tags)
# Normal case: all tags must match (case-insensitive)
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
return all(tag in normalized_item_tags for tag in non_special_tags)
else:
# OR logic (default): item must have ANY include tag
return any(tag in include_tags for tag in (item_tags or []))
# OR logic (default): item must have ANY include tag (case-insensitive)
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
return bool(normalized_item_tags & include_tags)
items = [item for item in items if matches_include(item.get("tags"))]
@@ -333,7 +339,9 @@ class ModelFilterSet:
def matches_exclude(item_tags):
if not item_tags and "__no_tags__" in exclude_tags:
return True
return any(tag in exclude_tags for tag in (item_tags or []))
# Case-insensitive: normalize item tags
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
return bool(normalized_item_tags & exclude_tags)
items = [
item for item in items if not matches_exclude(item.get("tags"))

View File

@@ -532,6 +532,13 @@ class ModelScanner:
if not scan_result or not getattr(self, '_persistent_cache', None):
return
if self.is_cancelled():
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping _save_persistent_cache "
"after cancellation"
)
return
hash_snapshot = self._build_hash_index_snapshot(scan_result.hash_index)
loop = asyncio.get_event_loop()
try:
@@ -705,6 +712,7 @@ class ModelScanner:
# Determine the page type based on model type
# Scan for new data
scan_result = await self._gather_model_data()
if not self.is_cancelled():
await self._apply_scan_result(scan_result)
await self._save_persistent_cache(scan_result)
await self._sync_download_history(scan_result.raw_data, source='scan')
@@ -713,6 +721,11 @@ class ModelScanner:
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
f"found {len(scan_result.raw_data)} models"
)
else:
logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization cancelled "
f"after {time.time() - start_time:.2f} seconds"
)
except Exception as e:
logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}")
# Ensure cache is at least an empty structure on error
@@ -1067,8 +1080,11 @@ class ModelScanner:
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes)
if not model_data.get('sha256') and file_path:
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes).
# Respect hash_status='pending' (set by CheckpointScanner for large models) to defer
# hash calculation until on-demand — avoids reading entire checkpoint files at startup.
hash_status = model_data.get('hash_status', '')
if not model_data.get('sha256') and hash_status != 'pending' and file_path:
try:
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
sha256 = await calculate_sha256(file_path)
@@ -1093,6 +1109,13 @@ class ModelScanner:
if scan_result is None:
return
if self.is_cancelled():
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping _apply_scan_result "
"after cancellation"
)
return
self._hash_index = scan_result.hash_index
self._tags_count = dict(scan_result.tags_count)
self._excluded_models = list(scan_result.excluded_models)
@@ -1762,6 +1785,13 @@ class ModelScanner:
if not file_paths or self._cache is None:
return False
if self.is_cancelled():
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping cache update "
"after cancelled bulk delete"
)
return False
try:
# Get all models that need to be removed from cache
models_to_remove = [item for item in self._cache.raw_data if item['file_path'] in file_paths]

View File

@@ -12,7 +12,7 @@ import logging
import os
import sqlite3
import threading
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
@@ -26,6 +26,8 @@ class PersistedRecipeData:
raw_data: List[Dict]
file_stats: Dict[str, Tuple[float, int]] # json_path -> (mtime, size)
image_id_map: Dict[str, str] = field(default_factory=dict)
"""Precomputed mapping of civitai image_id → recipe_id."""
class PersistentRecipeCache:
@@ -116,6 +118,20 @@ class PersistentRecipeCache:
if not rows:
return None
# Restore precomputed image_id_map if available
image_id_map: Dict[str, str] = {}
try:
meta_row = conn.execute(
"SELECT value FROM cache_metadata WHERE key = ?",
("image_id_map",),
).fetchone()
if meta_row:
parsed = json.loads(meta_row["value"])
if isinstance(parsed, dict):
image_id_map = parsed
except Exception:
pass # missing or corrupt — rebuilt on next cache refresh
finally:
conn.close()
except FileNotFoundError:
@@ -138,14 +154,24 @@ class PersistentRecipeCache:
row["file_size"] or 0,
)
return PersistedRecipeData(raw_data=raw_data, file_stats=file_stats)
return PersistedRecipeData(
raw_data=raw_data,
file_stats=file_stats,
image_id_map=image_id_map,
)
def save_cache(self, recipes: List[Dict], json_paths: Optional[Dict[str, str]] = None) -> None:
def save_cache(
self,
recipes: List[Dict],
json_paths: Optional[Dict[str, str]] = None,
image_id_map: Optional[Dict[str, str]] = None,
) -> None:
"""Save all recipes to SQLite cache.
Args:
recipes: List of recipe dictionaries to persist.
json_paths: Optional mapping of recipe_id -> json_path for file stats.
image_id_map: Optional precomputed civitai image_id → recipe_id mapping.
"""
if not self.is_enabled():
return
@@ -186,6 +212,12 @@ class PersistentRecipeCache:
recipe_rows,
)
# Persist image_id_map for O(1) lookups on cache load
conn.execute(
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
("image_id_map", json.dumps(image_id_map or {})),
)
conn.commit()
logger.debug("Persisted %d recipes to cache", len(recipe_rows))
finally:
@@ -273,6 +305,29 @@ class PersistentRecipeCache:
except Exception as exc:
logger.debug("Failed to remove recipe %s from cache: %s", recipe_id, exc)
def save_image_id_map(self, image_id_map: Dict[str, str]) -> None:
"""Persist the image_id_map to cache_metadata without rewriting the full cache.
This is called after ``add_recipe`` / ``remove_recipe`` mutations so
the persistent copy does not go stale between full ``save_cache`` calls.
"""
if not self.is_enabled() or not self._schema_initialized:
return
try:
with self._db_lock:
conn = self._connect()
try:
conn.execute(
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
("image_id_map", json.dumps(image_id_map)),
)
conn.commit()
finally:
conn.close()
except Exception as exc:
logger.debug("Failed to persist image_id_map: %s", exc)
def get_indexed_recipe_ids(self) -> Set[str]:
"""Return all recipe IDs in the cache.

View File

@@ -1,6 +1,6 @@
import asyncio
from typing import Iterable, List, Dict, Optional
from dataclasses import dataclass
from dataclasses import dataclass, field
from operator import itemgetter
from natsort import natsorted
@@ -14,6 +14,15 @@ class RecipeCache:
sorted_by_date: List[Dict]
folders: List[str] | None = None
folder_tree: Dict | None = None
image_id_map: Dict[str, str] = field(default_factory=dict)
"""Mapping of civitai image_id → recipe_id, precomputed at cache build time.
Built once during cache initialization (O(n)) so that
``check_image_exists`` and ``import_from_url`` duplicate checks
can look up image_id in O(1) instead of scanning all recipes.
Recipes imported from local files have no valid civitai image_id
and are naturally excluded from this map.
"""
def __post_init__(self):
self._lock = asyncio.Lock()

View File

@@ -20,6 +20,7 @@ from .metadata_service import get_default_metadata_provider
from .checkpoint_scanner import CheckpointScanner
from .settings_manager import get_settings_manager
from .recipes.errors import RecipeNotFoundError
from ..utils.civitai_utils import extract_civitai_image_id
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
from natsort import natsorted
import sys
@@ -532,7 +533,21 @@ class RecipeScanner:
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)
self._cache.image_id_map = self._build_image_id_map()
self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
)
else:
# Use persisted map, or rebuild if empty (e.g. first startup
# after deploying the image_id_map feature).
if persisted.image_id_map:
self._cache.image_id_map = dict(persisted.image_id_map)
else:
self._cache.image_id_map = self._build_image_id_map()
if self._cache.image_id_map:
self._persistent_cache.save_image_id_map(
self._cache.image_id_map
)
return self._cache
else:
# Partial update: some files changed
@@ -545,8 +560,11 @@ class RecipeScanner:
self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration)
self._backfill_source_path_if_needed(recipes, json_paths)
self._cache.image_id_map = self._build_image_id_map()
# Persist updated cache
self._persistent_cache.save_cache(recipes, json_paths)
self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
)
return self._cache
# Fall back to full directory scan
@@ -558,9 +576,12 @@ class RecipeScanner:
self._cache.raw_data = recipes
self._update_folder_metadata(self._cache)
self._sort_cache_sync()
self._cache.image_id_map = self._build_image_id_map()
# Persist for next startup
self._persistent_cache.save_cache(recipes, json_paths)
self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
)
return self._cache
except Exception as e:
@@ -832,6 +853,28 @@ class RecipeScanner:
except Exception as e:
logger.error(f"Error sorting recipe cache: {e}")
def _build_image_id_map(self) -> Dict[str, str]:
"""Build civitai image_id → recipe_id mapping from cached recipes.
Only recipes with a valid CivitAI image URL source_path produce an
entry. Recipes imported from local files are naturally excluded.
"""
mapping: Dict[str, str] = {}
if not self._cache:
return mapping
for recipe in getattr(self._cache, "raw_data", []):
if not isinstance(recipe, dict):
continue
source = recipe.get("source_path")
if not source:
continue
image_id = extract_civitai_image_id(source)
if image_id and image_id not in mapping:
recipe_id = recipe.get("id")
if recipe_id is not None:
mapping[image_id] = str(recipe_id)
return mapping
async def _wait_for_lora_scanner(self) -> None:
"""Ensure the LoRA scanner has initialized before recipe enrichment."""
@@ -1296,11 +1339,20 @@ class RecipeScanner:
# Update FTS index
self._update_fts_index_for_recipe(recipe_data, "add")
source = recipe_data.get("source_path")
if source:
image_id = extract_civitai_image_id(source)
if image_id:
recipe_id_value = recipe_data.get("id")
if recipe_id_value is not None:
cache.image_id_map[image_id] = str(recipe_id_value)
# Persist to SQLite cache
if self._persistent_cache:
recipe_id = str(recipe_data.get("id", ""))
json_path = self._json_path_map.get(recipe_id, "")
self._persistent_cache.update_recipe(recipe_data, json_path)
self._persistent_cache.save_image_id_map(cache.image_id_map)
async def remove_recipe(self, recipe_id: str) -> bool:
"""Remove a recipe from the cache by ID."""
@@ -1319,9 +1371,15 @@ class RecipeScanner:
# Update FTS index
self._update_fts_index_for_recipe(recipe_id, "remove")
# Remove any image_id entry pointing to this recipe
stale = [k for k, v in cache.image_id_map.items() if v == recipe_id]
for k in stale:
del cache.image_id_map[k]
# Remove from SQLite cache
if self._persistent_cache:
self._persistent_cache.remove_recipe(recipe_id)
self._persistent_cache.save_image_id_map(cache.image_id_map)
self._json_path_map.pop(recipe_id, None)
return True
@@ -1332,14 +1390,21 @@ class RecipeScanner:
cache = await self.get_cached_data()
removed = await cache.bulk_remove(recipe_ids, resort=False)
if removed:
removed_ids = {str(r.get("id", "")) for r in removed}
stale = [k for k, v in cache.image_id_map.items() if v in removed_ids]
for k in stale:
del cache.image_id_map[k]
self._schedule_resort()
# Update FTS index and persistent cache for each removed recipe
for recipe in removed:
recipe_id = str(recipe.get("id", ""))
self._update_fts_index_for_recipe(recipe_id, "remove")
if self._persistent_cache:
self._persistent_cache.remove_recipe(recipe_id)
self._json_path_map.pop(recipe_id, None)
if self._persistent_cache:
self._persistent_cache.save_image_id_map(cache.image_id_map)
return len(removed)
async def scan_all_recipes(self) -> List[Dict]:

View File

@@ -176,6 +176,24 @@ class RecipeAnalysisService:
self._exif_utils.extract_image_metadata, temp_path
)
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 {},
recipe_scanner=recipe_scanner,

View File

@@ -49,8 +49,18 @@ class RecipePersistenceService:
tags: Iterable[str],
metadata: Optional[dict[str, Any]],
extension: str | None = None,
recipe_id: str | None = None,
target_dir: str | None = None,
) -> 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 = []
if not name:
@@ -63,10 +73,10 @@ class RecipePersistenceService:
)
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)
recipe_id = str(uuid.uuid4())
recipe_id = recipe_id or str(uuid.uuid4())
# Handle video formats by bypassing optimization and metadata embedding
is_video = extension in [".mp4", ".webm"]
@@ -119,6 +129,18 @@ class RecipePersistenceService:
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_path = os.path.join(recipes_dir, json_filename)
json_path = os.path.normpath(json_path)

View File

@@ -188,6 +188,25 @@ class ServiceRegistry:
logger.debug(f"Created and registered {service_name}")
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
async def get_backup_service(cls):
"""Get or create the backup service."""

View File

@@ -91,7 +91,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"autoplay_on_hover": False,
"display_density": "default",
"card_info_display": "always",
"show_folder_sidebar": True,
"include_trigger_words": False,
"compact_mode": False,
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
@@ -106,6 +105,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"download_skip_base_models": [],
"backup_auto_enabled": True,
"backup_retention_count": 5,
"use_new_license_icons": True,
}
@@ -134,6 +134,9 @@ class SettingsManager:
self._template_path = (
Path(__file__).resolve().parents[2] / "settings.json.example"
)
# Known placeholder value in settings.json.example; any file containing
# this value should be treated as "not configured".
self._TEMPLATE_PLACEHOLDER_API_KEY = "your_civitai_api_key_here"
self.settings = self._load_settings()
self._migrate_setting_keys()
self._ensure_default_settings()
@@ -165,6 +168,12 @@ class SettingsManager:
self._original_disk_payload = copy.deepcopy(data)
if self._matches_template_payload(data):
self._preserve_disk_template = True
# Clean up the template placeholder so it is not treated
# as a real key (affects both the frontend boolean and
# the downloader's Authorization header).
placeholder = self._TEMPLATE_PLACEHOLDER_API_KEY
if data.get("civitai_api_key") == placeholder:
data["civitai_api_key"] = ""
return data
except json.JSONDecodeError as exc:
logger.error("Failed to parse settings.json: %s", exc)

View File

@@ -36,9 +36,9 @@ class TagUpdateService:
if isinstance(tag, str) and tag.strip():
# Convert all tags to lowercase to avoid case sensitivity issues on Windows
normalized = tag.strip().lower()
if normalized.lower() not in existing_lower:
if normalized not in existing_lower:
existing_tags.append(normalized)
existing_lower.append(normalized.lower())
existing_lower.append(normalized)
tags_added.append(normalized)
metadata["tags"] = existing_tags

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import time
from typing import Any, Dict, List, Optional, Protocol, Sequence
from ..metadata_sync_service import MetadataSyncService
@@ -62,26 +63,48 @@ class BulkMetadataRefreshUseCase:
]
total_to_process = len(to_process)
initial_skipped = total_models - total_to_process # models excluded from fetch queue
processed = 0
success = 0
skipped_count = initial_skipped
handled_count = initial_skipped
needs_resort = False
start_time = time.monotonic()
failures: List[Dict[str, str]] = []
self._service.scanner.reset_cancellation()
async def emit(status: str, **extra: Any) -> None:
if progress_callback is None:
return
payload = {"status": status, "total": total_to_process, "processed": processed, "success": success}
payload = {
"status": status,
"total": total_models,
"processed": processed,
"success": success,
"failure_count": len(failures),
"skipped_count": skipped_count,
"handled": handled_count,
"elapsed_seconds": int(time.monotonic() - start_time),
}
# Only include full failure details in terminal emits (completed,
# cancelled, rate_limited) to avoid serializing the list on every
# per-model progress update.
if failures and status in ("completed", "cancelled", "rate_limited"):
payload["failures"] = failures
payload.update(extra)
await progress_callback.on_progress(payload)
await emit("started")
RATE_LIMIT_ABORT_THRESHOLD = 3
consecutive_rate_limits = 0
for model in to_process:
if self._service.scanner.is_cancelled():
self._logger.info("Bulk metadata refresh cancelled by user")
await emit("cancelled", processed=processed, success=success)
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models}
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
try:
original_name = model.get("model_name")
@@ -101,31 +124,76 @@ class BulkMetadataRefreshUseCase:
model["hash_status"] = "completed"
else:
self._logger.error(f"Failed to calculate hash for {file_path}")
failures.append({"name": model.get("model_name", file_path or "Unknown"), "error": "Failed to calculate hash"})
processed += 1
handled_count += 1
continue
else:
self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}")
skipped_count += 1
processed += 1
handled_count += 1
continue
# Skip models without valid hash
if not model.get("sha256"):
self._logger.warning(f"Skipping model without hash: {file_path}")
skipped_count += 1
processed += 1
handled_count += 1
continue
await MetadataManager.hydrate_model_data(model)
result, _ = await self._metadata_sync.fetch_and_update_model(
result, error_msg = await self._metadata_sync.fetch_and_update_model(
sha256=model["sha256"],
file_path=model["file_path"],
model_data=model,
update_cache_func=self._service.scanner.update_single_model_cache,
)
if not result and error_msg and "Rate limited" in error_msg:
consecutive_rate_limits += 1
else:
consecutive_rate_limits = 0
if not result:
current_name = model.get("model_name", file_path or "Unknown")
failures.append({"name": current_name, "error": error_msg or "Unknown error"})
self._logger.warning("Failed to fetch metadata for %s: %s", current_name, error_msg)
if consecutive_rate_limits >= RATE_LIMIT_ABORT_THRESHOLD:
# The current model was attempted and failed due to rate limiting;
# count it before aborting so the summary is consistent.
processed += 1
handled_count += 1
self._logger.warning(
"Bulk metadata refresh aborted: %d consecutive rate limits detected. "
"Processed %d/%d models.",
consecutive_rate_limits,
processed,
total_to_process,
)
await emit(
"rate_limited",
)
return {
"success": False,
"message": f"Rate limit detected; {total_to_process - processed} models skipped",
"processed": processed,
"updated": success,
"total": total_models,
"failures": failures,
"failure_count": len(failures),
"skipped_count": skipped_count,
"elapsed_seconds": int(time.monotonic() - start_time),
}
if result:
success += 1
if original_name != model.get("model_name"):
needs_resort = True
processed += 1
handled_count += 1
await emit(
"processing",
processed=processed,
@@ -134,6 +202,9 @@ class BulkMetadataRefreshUseCase:
)
except Exception as exc: # pragma: no cover - logging path
processed += 1
handled_count += 1
current_name = model.get("model_name", model.get("file_path", "Unknown"))
failures.append({"name": current_name, "error": str(exc)})
self._logger.error(
"Error fetching CivitAI data for %s: %s",
model.get("file_path"),
@@ -150,7 +221,7 @@ class BulkMetadataRefreshUseCase:
f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})"
)
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models}
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
@staticmethod
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:

View File

@@ -31,6 +31,8 @@ PREVIEW_EXTENSIONS = [
".mp4",
".gif",
".webm",
".avif",
".jxl",
]
# Card preview image width
@@ -41,7 +43,7 @@ EXAMPLE_IMAGE_WIDTH = 832
# Supported media extensions for example downloads
SUPPORTED_MEDIA_EXTENSIONS = {
"images": [".jpg", ".jpeg", ".png", ".webp", ".gif"],
"images": [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif", ".jxl"],
"videos": [".mp4", ".webm"],
}

View File

@@ -12,6 +12,18 @@ from ..services.settings_manager import get_settings_manager
_HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}")
# Filesystem/metadata files that are never created by the example images system
# and are safe to ignore during validation. The cleanup service only operates on
# directories, so these files pose no data-loss risk.
_SAFE_FILENAMES: frozenset[str] = frozenset({
".DS_Store", # macOS folder metadata
"Thumbs.db", # Windows thumbnail cache
"desktop.ini", # Windows folder customization
".localized", # macOS folder name localization
".gitkeep", # Placeholder to keep empty dirs in git
".gitignore", # Git ignore rules
})
logger = logging.getLogger(__name__)
@@ -180,6 +192,22 @@ def is_hash_folder(name: str) -> bool:
return bool(_HEX_PATTERN.fullmatch(name or ""))
def _is_safe_ignorable_entry(item: str, item_path: str) -> bool:
"""Return True if *item* is a harmless system/hidden file we can skip.
These files are never created by the example images system and are safe to
ignore because the cleanup/delete operations only act on **directories**,
never on individual files (other than ``.download_progress.json``).
"""
if item in _SAFE_FILENAMES:
return True
# Hide Unix hidden files (dotfiles) that are regular files,
# since the cleanup system never deletes or moves files.
if item.startswith(".") and os.path.isfile(item_path):
return True
return False
def is_valid_example_images_root(folder_path: str) -> bool:
"""Check whether a folder looks like a dedicated example images root."""
@@ -190,9 +218,16 @@ def is_valid_example_images_root(folder_path: str) -> bool:
for item in items:
item_path = os.path.join(folder_path, item)
# .download_progress.json is an expected metadata file — check before
# the generic dotfile rule so it stays explicitly documented.
if item == ".download_progress.json" and os.path.isfile(item_path):
continue
# Skip harmless system/hidden files — cleanup only touches directories
if _is_safe_ignorable_entry(item, item_path):
continue
if os.path.isdir(item_path):
if is_hash_folder(item):
continue
@@ -211,6 +246,41 @@ def is_valid_example_images_root(folder_path: str) -> bool:
return True
def find_non_compliant_items_in_example_images_root(folder_path: str) -> list[str]:
"""Return the names of items that prevent *folder_path* from being a valid
example images root, or an empty list if the folder is valid.
This mirrors ``is_valid_example_images_root`` but **returns** the offending
names instead of a boolean, so callers can produce actionable error messages.
"""
try:
items = os.listdir(folder_path)
except OSError as exc:
return [f"<cannot list directory: {exc}>"]
offending: list[str] = []
for item in items:
item_path = os.path.join(folder_path, item)
# Same skip rules as is_valid_example_images_root
if item == ".download_progress.json" and os.path.isfile(item_path):
continue
if _is_safe_ignorable_entry(item, item_path):
continue
if os.path.isdir(item_path):
if is_hash_folder(item):
continue
if item == "_deleted":
continue
if _library_folder_has_only_hash_dirs(item_path):
continue
offending.append(item)
return offending
def _library_folder_has_only_hash_dirs(path: str) -> bool:
"""Return True when a library subfolder only contains hash folders or metadata files."""

View File

@@ -62,6 +62,10 @@ class ExampleImagesProcessor:
return '.gif'
elif content.startswith(b'RIFF') and b'WEBP' in content[:12]:
return '.webp'
elif len(content) >= 12 and content[4:8] == b'ftyp' and b'avif' in content[8:24]:
return '.avif'
elif content.startswith(b'\x00\x00\x00\x0cJXL \x0d\x0a\x87\x0a'):
return '.jxl'
elif content.startswith(b'\x00\x00\x00\x18ftypmp4') or content.startswith(b'\x00\x00\x00\x20ftypmp4'):
return '.mp4'
elif content.startswith(b'\x1A\x45\xDF\xA3'):
@@ -75,6 +79,8 @@ class ExampleImagesProcessor:
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/avif': '.avif',
'image/jxl': '.jxl',
'video/mp4': '.mp4',
'video/webm': '.webm',
'video/quicktime': '.mov'

View File

@@ -1,17 +1,125 @@
import json
import logging
import os
import struct
from io import BytesIO
from typing import Any, Optional
import piexif
from PIL import Image, PngImagePlugin
try:
import brotli
_BROTLI_AVAILABLE = True
except ImportError:
brotli = None
_BROTLI_AVAILABLE = False
logger = logging.getLogger(__name__)
class ExifUtils:
"""Utility functions for working with EXIF data in images"""
@staticmethod
def _parse_isobmff_boxes(data: bytes, offset: int = 0) -> list[dict]:
boxes = []
while offset + 8 <= len(data):
size = struct.unpack('>I', data[offset:offset + 4])[0]
box_type = data[offset + 4:offset + 8]
if size == 0:
break
if size < 8 or offset + size > len(data):
break
box_data = data[offset + 8:offset + size]
boxes.append({'type': box_type, 'data': box_data, 'size': size})
offset += size
return boxes
@staticmethod
def _is_jxl_container(data: bytes) -> bool:
if len(data) < 32:
return False
return (
struct.unpack('>I', data[:4])[0] == 12
and data[4:8] == b'JXL '
and data[8:12] == bytes([0x0d, 0x0a, 0x87, 0x0a])
and struct.unpack('>I', data[12:16])[0] >= 16
and data[16:20] == b'ftyp'
and data[20:24] == b'jxl '
)
@staticmethod
def _is_avif_container(data: bytes) -> bool:
if len(data) < 16:
return False
for box in ExifUtils._parse_isobmff_boxes(data):
if box['type'] == b'ftyp' and b'avif' in box['data']:
return True
return False
# Max decompressed size for brotli metadata (2 MB)
_BROTLI_MAX_DECOMPRESSED = 2 * 1024 * 1024
@staticmethod
def _extract_isobmff_brotli(image_path: str) -> Optional[dict]:
try:
with open(image_path, 'rb') as f:
data = f.read()
except Exception:
return None
if ExifUtils._is_jxl_container(data):
boxes = ExifUtils._parse_isobmff_boxes(data, offset=12)
elif ExifUtils._is_avif_container(data):
boxes = ExifUtils._parse_isobmff_boxes(data)
else:
return None
brob = None
for box in boxes:
if box['type'] == b'brob':
brob = box
break
if brob is None:
return None
payload = brob['data']
if payload[:4] != b'comf':
return None
compressed = payload[4:]
if _BROTLI_AVAILABLE:
try:
decompressed = brotli.decompress(compressed)
if len(decompressed) > ExifUtils._BROTLI_MAX_DECOMPRESSED:
logger.warning(
"Brotli metadata too large (%d bytes, max %d), ignoring",
len(decompressed),
ExifUtils._BROTLI_MAX_DECOMPRESSED,
)
decompressed = None
except Exception:
decompressed = None
else:
decompressed = None
raw = decompressed if decompressed is not None else compressed
try:
meta = json.loads(raw.decode('utf-8'))
except Exception:
return None
result = {"parameters": None, "prompt": None, "workflow": None, "comment": None}
if isinstance(meta.get("prompt"), (dict, list)):
result["prompt"] = json.dumps(meta["prompt"])
elif isinstance(meta.get("prompt"), str):
result["prompt"] = meta["prompt"]
if isinstance(meta.get("workflow"), (dict, list)):
result["workflow"] = json.dumps(meta["workflow"])
elif isinstance(meta.get("workflow"), str):
result["workflow"] = meta["workflow"]
return result
@staticmethod
def _decode_user_comment(user_comment: Any) -> Optional[str]:
if user_comment is None:
@@ -43,6 +151,12 @@ class ExifUtils:
"comment": None,
}
ext = os.path.splitext(image_path)[1].lower()
if ext in ('.avif', '.jxl'):
brotli_meta = ExifUtils._extract_isobmff_brotli(image_path)
if brotli_meta:
return brotli_meta
with Image.open(image_path) as img:
info = getattr(img, "info", {}) or {}
@@ -149,7 +263,6 @@ class ExifUtils:
Optional[str]: Extracted metadata or None if not found
"""
try:
# Skip for video files
if image_path:
ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']:
@@ -177,10 +290,9 @@ class ExifUtils:
str: Path to the updated image
"""
try:
# Skip for video files
if image_path:
ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']:
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
return image_path
metadata_fields = ExifUtils._load_structured_metadata(image_path)
@@ -212,10 +324,9 @@ class ExifUtils:
def append_recipe_metadata(image_path, recipe_data) -> str:
"""Append recipe metadata to an image's EXIF data"""
try:
# Skip for video files
if image_path:
ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']:
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
return image_path
# First, extract existing metadata
@@ -327,10 +438,9 @@ class ExifUtils:
Tuple of (optimized_image_data, extension)
"""
try:
# Skip for video files early if it's a file path
if isinstance(image_data, str) and os.path.exists(image_data):
ext = os.path.splitext(image_data)[1].lower()
if ext in ['.mp4', '.webm']:
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
try:
with open(image_data, 'rb') as f:
return f.read(), ext

View File

@@ -34,12 +34,26 @@ def _get_hash_chunk_size_bytes() -> int:
async def calculate_sha256(file_path: str) -> str:
"""Calculate SHA256 hash of a file (full file content)."""
"""Calculate SHA256 hash of a file (full file content).
Uses ``posix_fadvise`` with ``POSIX_FADV_DONTNEED`` to avoid polluting the OS page
cache — critical on WSL where cached file pages live inside the VM and are not
accounted for in guest ``used`` memory, causing VmmemWSL to balloon.
On Windows/macOS where ``posix_fadvise`` is not available the hint is silently
skipped.
"""
sha256_hash = hashlib.sha256()
chunk_size = _get_hash_chunk_size_bytes()
with open(file_path, "rb") as f:
fd = f.fileno()
for byte_block in iter(lambda: f.read(chunk_size), b""):
sha256_hash.update(byte_block)
# Evict pages after reading so the data doesn't linger in the kernel page
# cache — on WSL this otherwise appears as unreclaimable VmmemWSL growth.
# Guard against platforms (Windows, macOS) that lack posix_fadvise.
if hasattr(os, "posix_fadvise") and hasattr(os, "POSIX_FADV_DONTNEED"):
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
return sha256_hash.hexdigest()
@@ -81,7 +95,7 @@ def read_safetensors_metadata(file_path: str) -> dict[str, Any]:
return {}
header = json.loads(header_bytes.decode("utf-8"))
return header.get("__metadata__", {})
except (OSError, json.JSONDecodeError, UnicodeDecodeError, struct.error):
except (OSError, json.JSONDecodeError, UnicodeDecodeError, struct.error, MemoryError, Exception):
return {}

View File

@@ -1,4 +1,5 @@
import os
import re
import json
import time
import asyncio
@@ -9,6 +10,7 @@ from typing import Dict, Set
from ..config import config
from ..services.service_registry import ServiceRegistry
from ..utils.settings_paths import get_settings_dir
# 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"
@@ -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
MODELS = "models"
LORAS = "loras"
EMBEDDINGS = "embeddings"
PROMPTS = "prompts"
if not standalone_mode:
from ..metadata_collector.metadata_registry import MetadataRegistry
# Import constants from metadata_collector to ensure consistency, but we have fallbacks defined above
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
LORAS = _LORAS
EMBEDDINGS = _EMBEDDINGS
PROMPTS = _PROMPTS
except ImportError:
pass # Use the local definitions
@@ -65,6 +71,7 @@ class UsageStats:
self.stats = {
"checkpoints": {}, # sha256 -> { total: count, history: { date: count } }
"loras": {}, # sha256 -> { total: count, history: { date: count } }
"embeddings": {}, # sha256 -> { total: count, history: { date: count } }
"total_executions": 0,
"last_save_time": 0
}
@@ -77,6 +84,7 @@ class UsageStats:
# Load existing stats if available
self._stats_file_path = self._get_stats_file_path()
self._migrate_from_old_location()
self._load_stats()
# Save interval in seconds
@@ -89,14 +97,38 @@ class UsageStats:
logger.debug("Usage statistics tracker initialized")
def _get_stats_file_path(self) -> str:
"""Get the path to the stats JSON file"""
if not config.loras_roots or len(config.loras_roots) == 0:
# If no lora roots are available, we can't save stats
# This will be handled by the caller
raise RuntimeError("No LoRA root directories configured. Cannot initialize usage statistics.")
"""Get the path to the stats JSON file in the settings directory."""
settings_dir = get_settings_dir(create=True)
return os.path.join(settings_dir, "stats", self.STATS_FILENAME)
# Use the first lora root
return os.path.join(config.loras_roots[0], self.STATS_FILENAME)
@staticmethod
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):
"""Backup the old stats file before conversion"""
@@ -115,6 +147,7 @@ class UsageStats:
new_stats = {
"checkpoints": {},
"loras": {},
"embeddings": {},
"total_executions": old_stats.get("total_executions", 0),
"last_save_time": old_stats.get("last_save_time", time.time())
}
@@ -142,19 +175,25 @@ 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")
return new_stats
def _is_old_format(self, stats):
"""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
if "loras" in stats and isinstance(stats["loras"], dict):
for hash_id, data in stats["loras"].items():
if isinstance(data, (int, float)):
return True
if "checkpoints" in stats and isinstance(stats["checkpoints"], dict):
for hash_id, data in stats["checkpoints"].items():
for category in ("loras", "checkpoints", "embeddings"):
if category in stats and isinstance(stats[category], dict):
for hash_id, data in stats[category].items():
if isinstance(data, (int, float)):
return True
@@ -182,6 +221,9 @@ class UsageStats:
if "loras" in loaded_stats and isinstance(loaded_stats["loras"], dict):
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:
self.stats["total_executions"] = loaded_stats["total_executions"]
@@ -304,6 +346,10 @@ class UsageStats:
if LORAS in metadata and isinstance(metadata[LORAS], dict):
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:
"""Increment usage counters for a resolved stats key."""
if stat_key not in self.stats[category]:
@@ -510,6 +556,55 @@ class UsageStats:
except Exception as e:
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):
"""Get current usage statistics"""
return self.stats
@@ -522,6 +617,9 @@ class UsageStats:
elif model_type == "lora":
if sha256 in self.stats["loras"]:
return self.stats["loras"][sha256]["total"]
elif model_type == "embedding":
if sha256 in self.stats["embeddings"]:
return self.stats["embeddings"][sha256]["total"]
return 0
async def process_execution(self, prompt_id):

View File

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

View File

@@ -1,4 +1,5 @@
aiohttp
aiohttp-socks
jinja2
safetensors
piexif
@@ -12,3 +13,5 @@ aiosqlite
beautifulsoup4
platformdirs
pyyaml
# brotli — ISOBMFF (AVIF/JXL) metadata decompression
brotli>=1.2.0

View File

@@ -34,6 +34,8 @@ import sys
from pathlib import Path
from typing import Any
from platformdirs import user_config_dir
logging.basicConfig(
level=logging.INFO,
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:
return portable
config_home = os.environ.get("XDG_CONFIG_HOME")
if config_home:
return Path(config_home).expanduser() / APP_NAME / "settings.json"
return Path.home() / ".config" / APP_NAME / "settings.json"
return Path(user_config_dir(APP_NAME, appauthor=False)) / "settings.json"
def load_json(path: Path) -> dict[str, Any]:

View File

@@ -39,6 +39,8 @@ import sys
from pathlib import Path
from typing import Any
from platformdirs import user_config_dir
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
@@ -68,10 +70,7 @@ def resolve_settings_path() -> Path:
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
return portable
config_home = os.environ.get("XDG_CONFIG_HOME")
if config_home:
return Path(config_home).expanduser() / APP_NAME / "settings.json"
return Path.home() / ".config" / APP_NAME / "settings.json"
return Path(user_config_dir(APP_NAME, appauthor=False)) / "settings.json"
def _load_json(path: Path) -> dict[str, Any]:

View File

@@ -2,6 +2,7 @@ import os
import sys
import json
from py.middleware.cache_middleware import cache_control
from py.middleware.error_middleware import api_json_error
from py.utils.settings_paths import ensure_settings_file
# Set environment variable to indicate standalone mode
@@ -157,7 +158,7 @@ class StandaloneServer:
def __init__(self):
self.app = web.Application(
logger=logger,
middlewares=[cache_control],
middlewares=[api_json_error, cache_control],
client_max_size=256 * 1024 * 1024,
handler_args={
"max_field_size": HEADER_SIZE_LIMIT,

View File

@@ -1,21 +1,20 @@
@import 'tokens/index.css';
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
/* Disable default scrolling */
}
/* 针对Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
scrollbar-color: var(--border-base) transparent;
}
/* 针对Webkit browsers (Chrome, Safari等) */
::-webkit-scrollbar {
width: 8px;
width: var(--scrollbar-width, 8px);
}
::-webkit-scrollbar-track {
@@ -24,116 +23,128 @@ body {
}
::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
background-color: var(--border-base);
border-radius: var(--radius-xs);
}
:root {
--bg-color: #ffffff;
--text-color: #333333;
--text-muted: #6c757d;
--card-bg: #ffffff;
--border-color: #e0e0e0;
--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;
/* 添加滚动条宽度变量 */
/* Shortcut styles */
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.12);
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
--shortcut-text: var(--text-color);
--shortcut-bg: var(--color-accent-subtle);
--shortcut-border: var(--color-accent-border);
--shortcut-text: var(--text-primary);
--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"] {
background-color: #1a1a1a !important;
background-color: var(--bg-base) !important;
color-scheme: dark;
}
html[data-theme="light"] {
background-color: #ffffff !important;
background-color: var(--bg-base) !important;
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 {
font-family: 'Segoe UI', sans-serif;
background: var(--bg-color);
color: var(--text-color);
font-family: var(--font-body);
background: var(--bg-base);
color: var(--text-primary);
display: flex;
flex-direction: column;
padding-top: 0;
/* Remove the padding-top */
}
.hidden {
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;
gap: 6px;
align-items: center;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-side);
max-height: 80vh;
overflow-y: auto;
scrollbar-width: thin;
@@ -75,7 +75,7 @@
width: 20px;
height: 40px;
align-self: center;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-side);
}
.toggle-alphabet-bar:hover {
@@ -99,7 +99,7 @@
min-width: 24px;
text-align: center;
font-size: 0.85em;
transition: all 0.2s ease;
transition: var(--transition-base);
border: 1px solid var(--border-color);
}
@@ -107,7 +107,7 @@
background: var(--lora-accent);
color: white;
transform: scale(1.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
}
.letter-chip.active {

View File

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

View File

@@ -76,7 +76,7 @@
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
transition: var(--transition-base);
background: var(--bg-color);
}
@@ -166,7 +166,7 @@
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s;
transition: var(--transition-base);
}
.back-btn:hover {
@@ -237,7 +237,7 @@
padding: 8px 10px;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: all 0.2s;
transition: var(--transition-base);
border: 1px solid transparent;
}
@@ -349,8 +349,8 @@
}
.progress-percentage {
font-size: 1.2em;
font-weight: 600;
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
color: var(--lora-accent);
}
@@ -365,9 +365,9 @@
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--lora-accent), oklch(from var(--lora-accent) calc(l + 0.1) c h));
border-radius: 4px;
transition: width 0.3s ease;
background: var(--lora-accent);
border-radius: var(--border-radius-xs);
transition: width var(--transition-base);
}
/* Progress Stats */
@@ -389,27 +389,26 @@
}
.stat-item.success {
border-left: 3px solid #00B87A;
border-left: 4px solid var(--color-success);
}
.stat-item.failed {
border-left: 3px solid var(--lora-error);
border-left: 4px solid var(--color-error);
}
.stat-item.skipped {
border-left: 3px solid var(--lora-warning);
border-left: 4px solid var(--color-warning);
}
.stat-label {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
font-size: var(--text-xs);
color: var(--text-secondary);
margin-bottom: 4px;
}
.stat-value {
font-size: 1.4em;
font-weight: 600;
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
color: var(--text-color);
}
@@ -425,8 +424,7 @@
}
.current-item-label {
color: var(--text-color);
opacity: 0.7;
color: var(--text-secondary);
flex-shrink: 0;
}
@@ -449,27 +447,29 @@
}
.results-header {
text-align: center;
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.results-icon {
font-size: 3em;
color: #00B87A;
margin-bottom: var(--space-1);
font-size: var(--text-xl);
color: var(--color-success);
flex-shrink: 0;
}
.results-icon.warning {
color: var(--lora-warning);
color: var(--color-warning);
}
.results-icon.error {
color: var(--lora-error);
color: var(--color-error);
}
.results-title {
font-size: 1.3em;
font-weight: 600;
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
color: var(--text-color);
}
@@ -493,27 +493,26 @@
}
.result-card.success {
border-left: 3px solid #00B87A;
border-left: 4px solid var(--color-success);
}
.result-card.failed {
border-left: 3px solid var(--lora-error);
border-left: 4px solid var(--color-error);
}
.result-card.skipped {
border-left: 3px solid var(--lora-warning);
border-left: 4px solid var(--color-warning);
}
.result-label {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
font-size: var(--text-xs);
color: var(--text-secondary);
margin-bottom: 4px;
}
.result-value {
font-size: 1.4em;
font-weight: 600;
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
color: var(--text-color);
}
@@ -527,13 +526,13 @@
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
gap: var(--space-2);
padding: var(--space-2);
cursor: pointer;
color: var(--lora-accent);
font-weight: 500;
font-weight: var(--weight-medium);
border-radius: var(--border-radius-xs);
transition: background 0.2s;
transition: background var(--transition-base);
}
.details-toggle:hover {
@@ -541,7 +540,7 @@
}
.details-toggle i {
transition: transform 0.2s;
transition: transform var(--transition-base);
}
.details-toggle.expanded i {
@@ -561,10 +560,10 @@
.result-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-color);
font-size: 0.9em;
font-size: var(--text-sm);
}
.result-item:last-child {
@@ -572,28 +571,23 @@
}
.result-item-status {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8em;
font-size: var(--text-sm);
flex-shrink: 0;
}
.result-item-status.success {
background: oklch(from #00B87A l c h / 0.2);
color: #00B87A;
color: var(--color-success);
}
.result-item-status.failed {
background: oklch(from var(--lora-error) l c h / 0.2);
color: var(--lora-error);
color: var(--color-error);
}
.result-item-status.skipped {
background: oklch(from var(--lora-warning) l c h / 0.2);
color: var(--lora-warning);
color: var(--color-warning);
}
.result-item-info {
@@ -610,8 +604,8 @@
}
.result-item-error {
font-size: 0.8em;
color: var(--lora-error);
font-size: var(--text-xs);
color: var(--color-error);
margin-top: 2px;
}
@@ -661,11 +655,11 @@
/* Completed State */
.batch-progress-container.completed .progress-bar {
background: #00B87A;
background: var(--color-success);
}
.batch-progress-container.completed .status-icon {
color: #00B87A;
color: var(--color-success);
}
.batch-progress-container.completed .status-icon i {

View File

@@ -1,12 +1,12 @@
/* 卡片网格布局 */
/* Card grid layout */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */
gap: 12px; /* Consistent gap for both row and column spacing */
row-gap: 20px; /* Increase vertical spacing between rows */
margin-top: var(--space-2);
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
padding-top: 4px;
padding-bottom: 4px;
width: 100%; /* Ensure it takes full width of container */
max-width: 1400px; /* Base container width */
margin-left: auto;
@@ -19,7 +19,7 @@
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-base);
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 */
max-width: 260px; /* Base size */
min-width: 200px; /* Prevent cards from becoming too narrow */
@@ -33,7 +33,8 @@
.model-card:hover {
transform: translateY(-2px);
background: oklch(100% 0 0 / 0.6);
box-shadow: var(--shadow-md);
border-color: var(--lora-accent);
}
.model-card:focus-visible {
@@ -277,7 +278,7 @@
left: 0;
right: 0;
background: linear-gradient(transparent 15%, oklch(0% 0 0 / 0.75));
backdrop-filter: blur(8px);
backdrop-filter: blur(var(--card-blur-amount, 8px));
color: white;
padding: var(--space-1);
display: flex;
@@ -293,7 +294,7 @@
left: 0;
right: 0;
background: linear-gradient(oklch(0% 0 0 / 0.75), transparent 85%);
backdrop-filter: blur(8px);
backdrop-filter: blur(var(--card-blur-amount, 8px));
color: white;
padding: var(--space-1);
display: flex;
@@ -353,21 +354,26 @@
}
.card-actions {
flex-shrink: 0;
display: flex;
gap: var(--space-1); /* Use gap instead of margin for spacing between icons */
align-items: center;
gap: var(--space-1);
align-items: flex-end;
align-self: flex-end;
}
.card-actions i:hover {
.card-actions i:hover,
.card-actions i:focus-visible {
opacity: 0.9;
transform: scale(1.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 {
color: #ffc107 !important; /* Gold color for favorites */
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
color: var(--favorite-color) !important;
text-shadow: 0 0 5px var(--favorite-glow);
}
@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 {
margin-top: var(--space-1);
}
@@ -411,9 +409,13 @@
text-shadow: none;
}
.model-link a:hover {
.model-link a:hover,
.model-link a:focus-visible {
opacity: 0.8;
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 */
@@ -438,7 +440,7 @@
.base-model {
display: inline-block;
background: #f0f0f0;
background: var(--surface-hover, oklch(95% 0 0));
padding: 2px 6px;
border-radius: var(--border-radius-xs);
margin-right: 6px;

View File

@@ -5,14 +5,14 @@
position: sticky; /* Keep the sticky position */
top: var(--space-1);
width: 100%;
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); /* Use accent color with low opacity */
background-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.1); /* Use accent color with low opacity */
color: var(--text-color);
border-top: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3); /* Add top border with accent color */
border-bottom: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */
border-top: 1px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.3); /* Add top border with accent color */
border-bottom: 1px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.4); /* Make bottom border stronger */
z-index: var(--z-overlay);
padding: 12px 0;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); /* Stronger shadow */
transition: all 0.3s ease;
box-shadow: var(--shadow-lg); /* Stronger shadow */
transition: var(--transition-slow);
margin-bottom: 20px;
}
@@ -41,7 +41,7 @@
.duplicates-banner i.fa-exclamation-triangle {
font-size: 18px;
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
}
.duplicates-banner .banner-actions {
@@ -65,12 +65,12 @@
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.duplicates-banner button.btn-exit-mode:hover {
background-color: var(--bg-color);
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
transform: translateY(-1px);
}
@@ -86,16 +86,16 @@
background: var(--card-bg);
color: var(--text-color);
font-size: 0.85em;
transition: all 0.2s ease;
transition: var(--transition-base);
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
box-shadow: var(--shadow-xs);
}
.duplicates-banner button:hover {
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
background: var(--bg-color);
transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-sm);
}
.duplicates-banner button.btn-exit {
@@ -117,12 +117,12 @@
/* Duplicate groups */
.duplicate-group {
position: relative;
border: 2px solid oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
border: 2px solid oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
border-radius: var(--border-radius-base);
padding: 16px;
margin-bottom: 24px;
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 */
max-width: 1400px;
margin-left: auto;
@@ -152,7 +152,7 @@
display: flex;
justify-content: space-between;
align-items: center;
border-left: 4px solid oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); /* Add accent border on the left */
border-left: 4px solid oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h)); /* Add accent border on the left */
}
.duplicate-group-header span:last-child {
@@ -173,17 +173,17 @@
background: var(--card-bg);
color: var(--text-color);
font-size: 0.85em;
transition: all 0.2s ease;
transition: var(--transition-base);
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
box-shadow: var(--shadow-xs);
margin-left: 8px;
}
.duplicate-group-header button:hover {
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
background: var(--bg-color);
transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-sm);
}
.card-group-container {
@@ -230,34 +230,34 @@
justify-content: center;
cursor: pointer;
z-index: 1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
transition: var(--transition-base);
}
.group-toggle-btn:hover {
border-color: var(--lora-accent-l) var(--lora-accent-c) var (--lora-accent-h);
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-sm);
}
/* Duplicate card styling */
.model-card.duplicate {
position: relative;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.model-card.duplicate:hover {
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
}
.model-card.duplicate.latest {
border-style: solid;
border-color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
border-color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
}
.model-card.duplicate-selected {
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);
border: 2px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
box-shadow: var(--shadow-md);
}
.model-card .selector-checkbox {
@@ -276,7 +276,7 @@
position: absolute;
top: 10px;
left: 10px;
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
background: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
color: white;
font-size: 12px;
padding: 2px 6px;
@@ -290,7 +290,7 @@
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
box-shadow: var(--shadow-lg);
padding: 10px;
z-index: 1000;
max-width: 350px;
@@ -328,7 +328,7 @@
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border-color);
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
font-weight: bold;
word-break: break-all; /* Ensure long hashes wrap properly */
}
@@ -351,12 +351,12 @@
}
.verification-badge.verified {
background-color: oklch(70% 0.2 140); /* Green for verified */
background-color: var(--color-success); /* Green for verified */
color: white;
}
.verification-badge.mismatch {
background-color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
background-color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
color: white;
}
@@ -366,7 +366,7 @@
/* Hash Mismatch Styling */
.model-card.duplicate.hash-mismatch {
border: 2px dashed oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
border: 2px dashed oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
opacity: 0.85;
position: relative;
}
@@ -380,8 +380,8 @@
bottom: 0;
background: repeating-linear-gradient(
45deg,
oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h) / 0.05),
oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h) / 0.05) 10px,
oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h) / 0.05),
oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h) / 0.05) 10px,
transparent 10px,
transparent 20px
);
@@ -398,7 +398,7 @@
position: absolute;
top: 10px;
left: 10px; /* Changed from right:10px to left:10px */
background: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
background: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
color: white;
font-size: 12px;
padding: 3px 8px;
@@ -417,7 +417,7 @@
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border-color);
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
color: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
font-weight: bold;
}
@@ -432,12 +432,12 @@
border-radius: var(--border-radius-xs);
font-size: 0.85em;
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.btn-verify-hashes:hover {
background: var(--bg-color);
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
border-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
transform: translateY(-1px);
}
@@ -461,7 +461,7 @@
position: absolute;
top: -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;
}
@@ -493,12 +493,12 @@
cursor: help;
font-size: 16px;
margin-left: 8px;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.help-icon:hover {
opacity: 1;
color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
}
/* Help tooltip */
@@ -511,7 +511,7 @@
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: 12px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: var(--shadow-elevated);
z-index: var(--z-overlay);
font-size: 0.9em;
margin-top: 10px;
@@ -572,16 +572,16 @@
/* In dark mode, add additional distinction */
html[data-theme="dark"] .duplicates-banner {
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.4); /* 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 */
box-shadow: var(--shadow-dark-lg); /* Stronger shadow in dark mode */
background-color: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.15); /* Slightly stronger background in dark mode */
}
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 {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
box-shadow: var(--shadow-elevated);
}
/* Styles for disabled controls during duplicates mode */
@@ -598,11 +598,11 @@ html[data-theme="dark"] .help-tooltip {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
box-shadow: 0 0 0 2px oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.25);
position: relative;
z-index: 5;
}
#findDuplicatesBtn.active:hover {
background: oklch(calc(var(--lora-accent-l) - 5%) var(--lora-accent-c) var(--lora-accent-h));
background: oklch(calc(var(--color-accent-l) - 5%) var(--color-accent-c) var(--color-accent-h));
}

View File

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

View File

@@ -7,7 +7,7 @@
height: 48px;
/* Reduced height */
width: 100%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
/* Slightly stronger shadow */
}
@@ -134,14 +134,14 @@
background: var(--input-bg, var(--card-bg));
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm, 6px);
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: border-color var(--transition-base), box-shadow var(--transition-base);
box-shadow: var(--shadow-header);
overflow: hidden;
}
.header-search .search-container:focus-within {
border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px var(--lora-accent);
box-shadow: var(--shadow-header), 0 0 0 1px var(--lora-accent);
}
.header-search input {
@@ -183,7 +183,7 @@
color: var(--text-muted);
cursor: pointer;
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 {
@@ -191,9 +191,11 @@
}
.header-search .search-options-toggle:hover,
.header-search .search-filter-toggle:hover {
.header-search .search-filter-toggle:hover,
.header-search .search-filter-toggle:focus-visible {
background: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
outline: none;
}
.header-search .filter-badge {
@@ -269,7 +271,7 @@
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
transition: background-color var(--transition-base), color var(--transition-base), transform var(--transition-base);
position: relative;
}
@@ -281,7 +283,6 @@
.theme-toggle {
position: relative;
/* Ensure relative positioning for the container */
}
.theme-toggle .light-icon,
@@ -291,17 +292,14 @@
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/* Center perfectly */
opacity: 0;
transition: opacity 0.3s ease;
}
/* Default state shows dark icon */
.theme-toggle .dark-icon {
opacity: 1;
}
/* Light theme shows light icon */
.theme-toggle.theme-light .light-icon {
opacity: 1;
}
@@ -311,7 +309,6 @@
opacity: 0;
}
/* Dark theme shows dark icon */
.theme-toggle.theme-dark .dark-icon {
opacity: 1;
}
@@ -321,7 +318,6 @@
opacity: 0;
}
/* Auto theme shows auto icon */
.theme-toggle.theme-auto .auto-icon {
opacity: 1;
}
@@ -331,6 +327,201 @@
opacity: 0;
}
.theme-popover {
display: none;
position: fixed;
background: var(--surface-base, #ffffff);
border: 1px solid var(--border-base, #e0e0e0);
border-radius: var(--radius-md, 8px);
box-shadow: var(--shadow-xl, 0 4px 16px rgba(0, 0, 0, 0.15));
padding: 12px;
min-width: 220px;
z-index: calc(var(--z-overlay) + 1);
animation: theme-popover-in 0.15s ease-out;
}
.theme-popover.active {
display: block;
}
@keyframes theme-popover-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.theme-popover-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.theme-popover-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary, #6c757d);
}
.theme-popover-divider {
height: 1px;
background: var(--border-base, #e0e0e0);
margin: 10px 0;
}
.theme-popover-modes {
display: flex;
gap: 6px;
}
.theme-mode-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 4px;
border: 1px solid var(--border-base, #e0e0e0);
border-radius: var(--radius-sm, 6px);
background: var(--surface-elevated, #ffffff);
color: var(--text-primary, #333333);
cursor: pointer;
font-size: 0.75rem;
transition: background-color var(--transition-base, 200ms ease),
border-color var(--transition-base, 200ms ease),
color var(--transition-base, 200ms ease);
}
.theme-mode-btn i {
font-size: 0.9rem;
}
.theme-mode-btn:hover {
background: var(--surface-hover, oklch(95% 0.02 256));
border-color: var(--color-accent, oklch(68% 0.28 256));
}
.theme-mode-btn.active {
background: var(--color-accent-subtle, oklch(68% 0.28 256 / 0.12));
border-color: var(--color-accent, oklch(68% 0.28 256));
color: var(--color-accent, oklch(68% 0.28 256));
}
.theme-popover-presets {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.theme-preset-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 4px;
border: 1px solid var(--border-base, #e0e0e0);
border-radius: var(--radius-sm, 6px);
background: var(--surface-elevated, #ffffff);
color: var(--text-primary, #333333);
cursor: pointer;
font-size: 0.7rem;
transition: background-color var(--transition-base, 200ms ease),
border-color var(--transition-base, 200ms ease),
color var(--transition-base, 200ms ease);
}
.theme-preset-btn:hover {
background: var(--surface-hover, oklch(95% 0.02 256));
border-color: var(--color-accent, oklch(68% 0.28 256));
}
.theme-preset-btn.active {
background: var(--color-accent-subtle, oklch(68% 0.28 256 / 0.12));
border-color: var(--color-accent, oklch(68% 0.28 256));
color: var(--color-accent, oklch(68% 0.28 256));
}
.preset-swatch {
display: inline-block;
width: 22px;
height: 22px;
border-radius: var(--radius-xs, 4px);
border: 1px solid var(--border-subtle, oklch(72% 0.03 256 / 0.45));
flex-shrink: 0;
transition: transform var(--transition-base, 200ms ease),
box-shadow var(--transition-base, 200ms ease);
}
/* Solid accent colors — each swatch shows the theme's accent color directly.
This matches the app's flat, token-driven design language instead of using
decorative gradients that clash with the matte aesthetic. */
.preset-swatch-default {
background: oklch(68% 0.28 256);
}
.preset-swatch-nord {
background: oklch(62% 0.18 213);
}
.preset-swatch-midnight {
background: oklch(52% 0.15 300);
}
.preset-swatch-monokai {
background: oklch(72% 0.24 190);
}
.preset-swatch-dracula {
background: oklch(68% 0.24 265);
}
.preset-swatch-solarized {
background: oklch(55% 0.18 175);
}
.theme-preset-btn.active .preset-swatch {
box-shadow: 0 0 0 2px var(--color-accent, oklch(68% 0.28 256));
}
.theme-preset-btn:hover .preset-swatch {
transform: scale(1.08);
}
/* Dark mode: use each preset's dark-mode accent lightness for visibility.
These match the --color-accent-l values from [data-theme="dark"][data-theme-preset="..."]
in tokens/colors.css so the swatch accurately previews what the theme looks like. */
[data-theme="dark"] .preset-swatch-default {
background: oklch(68% 0.28 256);
}
[data-theme="dark"] .preset-swatch-nord {
background: oklch(68% 0.18 213);
}
[data-theme="dark"] .preset-swatch-midnight {
background: oklch(68% 0.14 300);
}
[data-theme="dark"] .preset-swatch-monokai {
background: oklch(72% 0.24 190);
}
[data-theme="dark"] .preset-swatch-dracula {
background: oklch(72% 0.24 265);
}
[data-theme="dark"] .preset-swatch-solarized {
background: oklch(60% 0.18 175);
}
/* Badge styling */
.update-badge {
position: absolute;
@@ -341,7 +532,7 @@
background-color: var(--lora-error);
border-radius: 50%;
border: 2px solid var(--card-bg);
transition: all 0.2s ease;
transition: opacity var(--transition-base);
pointer-events: none;
opacity: 0;
}
@@ -362,13 +553,22 @@
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
transition: background-color var(--transition-base), color var(--transition-base);
flex-shrink: 0;
}
.hamburger-menu-btn:hover {
background: var(--lora-accent);
color: white;
.hamburger-menu-btn:hover,
.hamburger-menu-btn:focus-visible {
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 */
@@ -381,7 +581,7 @@
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm, 6px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
box-shadow: var(--shadow-toast);
padding: 0.5rem;
min-width: 160px;
z-index: var(--z-dropdown, 200);
@@ -401,7 +601,7 @@
border-radius: var(--border-radius-xs, 4px);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
transition: background-color var(--transition-base), color var(--transition-base);
font-size: 0.9rem;
white-space: nowrap;
}

View File

@@ -211,7 +211,7 @@
.lora-item.is-early-access {
background: rgba(0, 184, 122, 0.05);
border-left: 4px solid #00B87A;
border-left: 4px solid var(--color-success);
}
.lora-item.missing-locally {
@@ -310,7 +310,7 @@
.missing-lora-item.is-early-access {
background: rgba(0, 184, 122, 0.05);
border-left: 3px solid #00B87A;
border-left: 3px solid var(--color-success);
padding-left: 10px;
}
@@ -630,7 +630,7 @@
gap: 12px;
padding: 12px 16px;
background: rgba(0, 184, 122, 0.1);
border: 1px solid #00B87A;
border: 1px solid var(--color-success);
border-radius: var(--border-radius-sm);
color: var(--text-color);
margin-bottom: var(--space-2);
@@ -646,7 +646,7 @@
/* Specific styling for the early access warning container in import modal */
.early-access-warning .warning-icon {
color: #00B87A;
color: var(--color-success);
font-size: 1.2em;
}
@@ -757,7 +757,7 @@
position: relative;
border-radius: var(--border-radius-sm);
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
transition: transform 0.2s ease;
}

View File

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

View File

@@ -1,96 +0,0 @@
/* Keyboard navigation indicator and help */
.keyboard-nav-hint {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: help;
transition: all 0.2s ease;
margin-left: 8px;
}
.keyboard-nav-hint:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
}
.keyboard-nav-hint i {
font-size: 14px;
}
/* Tooltip styling */
.tooltip {
position: relative;
}
.tooltip .tooltiptext {
visibility: hidden;
width: 240px;
background-color: var(--lora-surface);
color: var(--text-color);
text-align: center;
border-radius: var(--border-radius-xs);
padding: 8px;
position: absolute;
z-index: 9999; /* Ensure tooltip appears above cards */
right: 120%; /* Position tooltip to the left of the icon */
top: 50%; /* Vertically center */
transform: translateY(-15%); /* Vertically center */
opacity: 0;
transition: opacity 0.3s;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
border: 1px solid var(--lora-border);
font-size: 0.85em;
line-height: 1.4;
}
.tooltip .tooltiptext::after {
content: "";
position: absolute;
top: 50%; /* Vertically center arrow */
left: 100%; /* Arrow on the right side */
margin-top: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent transparent transparent var(--lora-border); /* Arrow points right */
}
.tooltip:hover .tooltiptext {
visibility: visible;
opacity: 1;
}
/* Keyboard shortcuts table */
.keyboard-shortcuts {
width: 100%;
border-collapse: collapse;
margin-top: 5px;
}
.keyboard-shortcuts td {
padding: 4px;
text-align: left;
}
.keyboard-shortcuts td:first-child {
font-weight: bold;
width: 40%;
}
.key {
display: inline-block;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 1px 5px;
font-size: 0.8em;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}

View File

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

View File

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

View File

@@ -72,6 +72,10 @@
margin-left: auto;
}
.modal-header-actions .license-permissions {
margin-left: auto;
}
.license-restrictions {
display: flex;
align-items: center;
@@ -95,6 +99,41 @@
transform: translateY(-1px);
}
/* Set 2 — New style permission indicators */
.license-permissions {
display: flex;
gap: 4px;
align-items: center;
}
.license-icon-new {
width: 22px;
height: 22px;
display: inline-block;
border-radius: 4px;
background-color: var(--text-muted);
-webkit-mask: var(--license-icon-image) center/contain no-repeat;
mask: var(--license-icon-image) center/contain no-repeat;
transition: background-color 0.2s ease, transform 0.2s ease;
cursor: default;
outline: 2px solid transparent;
outline-offset: 1px;
}
.license-icon-new.allowed {
background-color: var(--color-success, #40c057);
outline-color: color-mix(in oklch, var(--color-success, #40c057) 30%, transparent);
}
.license-icon-new.denied {
background-color: var(--color-error, #fa5252);
outline-color: color-mix(in oklch, var(--color-error, #fa5252) 30%, transparent);
}
.license-icon-new:hover {
transform: translateY(-1px);
}
/* Info Grid */
.info-grid {
display: grid;
@@ -105,14 +144,14 @@
.info-item {
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-radius: var(--border-radius-sm);
}
/* 调整深色主题下的样式 */
/* Dark theme info item styles */
[data-theme="dark"] .info-item {
background: rgba(255, 255, 255, 0.03);
background: var(--surface-subtle);
border: 1px solid var(--lora-border);
}
@@ -140,18 +179,70 @@
/* Add specific styles for notes content */
.info-item.notes .editable-field [contenteditable] {
height: 60px; /* Keep initial modal layout stable regardless of note length */
min-height: 60px; /* Increase height for multiple lines */
max-height: 420px; /* Limit maximum height */
overflow: auto; /* Enable scrolling and resize handle for long content */
resize: vertical; /* Allow manual vertical resizing */
white-space: pre-wrap; /* Preserve line breaks */
line-height: 1.5; /* Improve readability */
padding: 8px 12px; /* Slightly increase padding */
min-height: 60px;
white-space: pre-wrap;
line-height: 1.5;
padding: 8px 12px;
}
/* Notes expand/collapse — collapsed by default; only applies when JS detects long content */
.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 {
font-family: monospace;
font-family: var(--font-mono);
font-size: 0.9em;
}
@@ -219,13 +310,13 @@
}
}
/* 修改 back-to-top 按钮样式,使其固定在 modal 内部 */
/* Back-to-top button pinned inside modal */
.modal-content .back-to-top {
position: sticky; /* 改用 sticky 定位 */
float: right; /* 使用 float 确保按钮在右侧 */
bottom: 20px; /* 距离底部的距离 */
margin-right: 20px; /* 右侧间距 */
margin-top: -56px; /* 负边距确保不占用额外空间 */
position: sticky;
float: right;
bottom: 20px;
margin-right: 20px;
margin-top: -56px;
width: 36px;
height: 36px;
border-radius: 50%;
@@ -239,7 +330,7 @@
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: all 0.3s ease;
transition: opacity var(--transition-slow), visibility var(--transition-slow), transform var(--transition-slow);
z-index: 10;
}
@@ -282,7 +373,7 @@
outline: none;
}
/* 合并编辑按钮样式 */
/* Consolidated edit button styles */
.edit-model-name-btn,
.edit-file-name-btn,
.edit-base-model-btn,
@@ -295,7 +386,7 @@
cursor: pointer;
padding: 2px 5px;
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);
}
@@ -317,7 +408,7 @@
.edit-base-model-btn:hover,
.edit-model-description-btn:hover,
.edit-version-name-btn:hover {
opacity: 0.8 !important;
opacity: 0.8;
background: rgba(0, 0, 0, 0.05);
}
@@ -335,7 +426,7 @@
}
.base-wrapper {
flex: 2; /* 分配更多空间给base model */
flex: 2; /* Allocate more space to base model */
}
/* Base model display and editing styles */
@@ -378,7 +469,7 @@
}
.size-wrapper span {
font-family: monospace;
font-family: var(--font-mono);
font-size: 0.9em;
opacity: 0.9;
}
@@ -395,7 +486,7 @@
margin: 0;
padding: var(--space-1);
border-radius: var(--border-radius-xs);
font-size: 1.5em !important;
font-size: 1.5em;
font-weight: 600;
line-height: 1.2;
color: var(--text-color);
@@ -431,7 +522,7 @@
color: var(--text-color);
cursor: pointer;
font-size: 0.95em;
transition: all 0.2s;
transition: var(--transition-base);
opacity: 0.7;
position: relative;
}
@@ -836,18 +927,18 @@
align-items: center;
gap: 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-radius: var(--border-radius-sm);
max-width: fit-content;
cursor: pointer;
transition: all 0.2s;
transition: var(--transition-base);
}
[data-theme="dark"] .creator-info,
[data-theme="dark"] .civitai-view,
[data-theme="dark"] .modal-send-btn {
background: rgba(255, 255, 255, 0.03);
background: var(--surface-subtle);
border: 1px solid var(--lora-border);
}
@@ -906,14 +997,14 @@
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.03);
background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-weight: 500;
font-size: 0.9em;
transition: all 0.2s;
transition: var(--transition-base);
}
.civitai-view i {
@@ -929,18 +1020,18 @@
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.03);
background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-weight: 500;
font-size: 0.9em;
transition: all 0.2s;
transition: var(--transition-base);
}
[data-theme="dark"] .modal-send-btn {
background: rgba(255, 255, 255, 0.03);
background: var(--surface-subtle);
border: 1px solid var(--lora-border);
}

View File

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

View File

@@ -111,8 +111,8 @@
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
transition: var(--transition-base);
box-shadow: var(--shadow-md);
padding: 0;
position: relative;
overflow: hidden;
@@ -120,7 +120,7 @@
.media-control-btn:hover {
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 {
@@ -205,7 +205,7 @@
z-index: 5;
max-height: 50%; /* Reduced to take less space */
overflow-y: auto;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-inset-top);
opacity: 0;
pointer-events: none;
}
@@ -220,7 +220,7 @@
/* Adjust to dark theme */
[data-theme="dark"] .image-metadata-panel {
background: var(--card-bg);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
box-shadow: var(--shadow-inset-top);
}
.metadata-content {
@@ -297,7 +297,7 @@
.metadata-prompt {
color: var(--text-color);
font-family: monospace;
font-family: var(--font-mono);
font-size: 0.85em;
white-space: pre-wrap;
}
@@ -312,7 +312,7 @@
opacity: 0.6;
cursor: pointer;
padding: 3px;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.copy-prompt-btn:hover {
@@ -409,7 +409,7 @@
border-radius: var(--border-radius-sm);
padding: var(--space-4);
text-align: center;
transition: all 0.3s ease;
transition: var(--transition-slow);
background: var(--lora-surface);
cursor: pointer;
}
@@ -455,9 +455,9 @@
}
.import-formats {
font-size: 0.8em !important;
opacity: 0.6 !important;
margin-top: var(--space-2) !important;
font-size: 0.8em;
opacity: 0.6;
margin-top: var(--space-2);
}
.select-files-btn {
@@ -471,7 +471,7 @@
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
transition: var(--transition-base);
}
.select-files-btn:hover {
@@ -481,7 +481,7 @@
/* For dark theme */
[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 */

View File

@@ -17,17 +17,22 @@
flex-wrap: nowrap;
gap: 6px;
align-items: center;
min-width: 0;
overflow: hidden;
}
.model-tag-compact {
/* 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-radius: var(--border-radius-xs);
padding: 2px 8px;
font-size: 0.75em;
color: var(--text-color);
white-space: nowrap;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Style for empty tags placeholder */
@@ -45,7 +50,7 @@
/* Adjust dark theme tag styles */
[data-theme="dark"] .model-tag-compact {
background: rgba(255, 255, 255, 0.03);
background: var(--surface-subtle);
border: 1px solid var(--lora-border);
}
@@ -73,14 +78,14 @@
background: var(--card-bg);
border: 1px solid var(--border-color);
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;
max-width: 400px;
z-index: 10;
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: all 0.2s ease;
transition: var(--transition-base);
pointer-events: none;
}
@@ -101,7 +106,7 @@
.tooltip-tag {
/* 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-radius: var(--border-radius-xs);
padding: 3px 8px;
@@ -111,15 +116,16 @@
/* Adjust dark theme tooltip tag styles */
[data-theme="dark"] .tooltip-tag {
background: rgba(255, 255, 255, 0.03);
background: var(--surface-hover);
border: 1px solid var(--lora-border);
}
/* Model Tags Edit Mode */
.model-tags-header {
display: flex;
justify-content: space-between;
justify-content: flex-start;
align-items: center;
overflow: hidden;
}
.edit-tags-btn {
@@ -130,8 +136,9 @@
cursor: pointer;
padding: 2px 5px;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
transition: var(--transition-base);
margin-left: var(--space-1);
flex-shrink: 0;
}
.edit-tags-btn.visible,

View File

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

View File

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

View File

@@ -61,7 +61,7 @@
max-height: 85vh;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
box-shadow: var(--shadow-dark-lg);
}
.media-viewer-video {

View File

@@ -5,7 +5,7 @@
border-radius: var(--border-radius-xs);
padding: 4px 0;
min-width: 180px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-dropdown);
z-index: 1000;
display: none;
backdrop-filter: blur(10px);
@@ -21,9 +21,11 @@
background: var(--lora-surface);
}
.context-menu-item:hover {
.context-menu-item:hover,
.context-menu-item:focus-visible {
background-color: var(--lora-accent);
color: var(--lora-text);
outline: none;
}
.context-menu-separator {
@@ -32,6 +34,12 @@
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 {
color: var(--danger-color);
}
@@ -75,7 +83,7 @@
border-radius: var(--border-radius-xs);
padding: 0;
min-width: 200px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-dropdown);
z-index: 1001;
backdrop-filter: blur(10px);
}
@@ -108,7 +116,7 @@
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
padding: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-modal);
z-index: var(--z-modal);
width: 300px;
display: none;
@@ -162,7 +170,7 @@
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.nsfw-level-btn:hover {
@@ -186,7 +194,7 @@
max-width: 350px;
max-height: 400px;
overflow-y: auto;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-dropdown);
z-index: var(--z-overlay);
display: none;
backdrop-filter: blur(10px);

View File

@@ -0,0 +1,171 @@
/* Metadata Refresh Result Modal — component styles only */
.metadata-refresh-result-modal {
max-width: 700px;
}
.refresh-summary-stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: var(--space-3) 0;
}
.stat-card {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-sm);
background: var(--surface-subtle);
border-left: 4px solid transparent;
font-size: var(--text-sm);
flex: 1;
min-width: 130px;
}
.stat-card-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.stat-card-label {
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: var(--leading-tight);
}
.stat-card-value {
font-weight: var(--weight-bold);
font-size: var(--text-lg);
color: var(--lora-text);
line-height: var(--leading-tight);
}
.stat-card-success {
border-left-color: var(--color-success);
}
.stat-card-failure {
border-left-color: var(--color-error);
}
.stat-card-skipped {
border-left-color: var(--color-warning);
}
.stat-card-total {
border-left-color: var(--lora-border);
}
.stat-card-time {
border-left-color: var(--lora-border);
}
.refresh-failures-section {
margin-bottom: var(--space-3);
}
.refresh-failures-section h4 {
margin: 0 0 var(--space-2) 0;
font-size: var(--text-base);
color: var(--color-error);
display: flex;
align-items: center;
gap: var(--space-1);
}
.refresh-failures-section h4 i {
font-size: 0.9em;
}
.failure-table-wrapper {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
}
.failure-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.failure-table th {
position: sticky;
top: 0;
background: var(--lora-surface);
border-bottom: 1px solid var(--lora-border);
padding: var(--space-1) var(--space-2);
text-align: left;
font-weight: var(--weight-semibold);
color: var(--text-secondary);
z-index: 1;
}
.failure-table td {
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--lora-border);
vertical-align: top;
}
.failure-table tr:last-child td {
border-bottom: none;
}
.failure-table tr:hover td {
background: var(--surface-subtle);
}
.failure-index {
width: 30px;
text-align: center;
color: var(--text-secondary);
}
.failure-name {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.failure-error {
color: var(--color-error);
font-size: var(--text-xs);
}
.refresh-success-message {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
margin-bottom: var(--space-3);
background: var(--surface-subtle);
border-left: 4px solid var(--color-success);
color: var(--lora-text);
border-radius: var(--border-radius-sm);
font-weight: var(--weight-medium);
}
.refresh-success-message i {
font-size: 1.2em;
flex-shrink: 0;
color: var(--color-success);
}
[data-theme="dark"] .failure-table th {
background: var(--lora-surface);
}
[data-theme="dark"] .failure-table td {
border-bottom-color: var(--lora-border);
}
[data-theme="dark"] .failure-table tr:hover td {
background: var(--surface-subtle);
}

View File

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

View File

@@ -51,7 +51,7 @@
background: var(--lora-surface);
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
font-family: var(--font-mono);
border: 1px solid var(--lora-border);
}

View File

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

View File

@@ -37,7 +37,7 @@
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
background: var(--bg-color);
margin: 1px;
position: relative;
@@ -45,7 +45,7 @@
.version-item:hover {
border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
z-index: 1;
}
@@ -156,7 +156,7 @@
background: var(--bg-color);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
display: flex;
align-items: center;
justify-content: center;
@@ -225,7 +225,7 @@
padding: 4px 8px;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
color: var(--text-color);
opacity: 0.7;
text-decoration: none;
@@ -272,7 +272,7 @@
padding: 4px 8px;
cursor: pointer;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
transition: var(--transition-base);
position: relative;
}
@@ -293,7 +293,7 @@
justify-content: center;
cursor: pointer;
border-radius: 2px;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.tree-expand-icon:hover {
@@ -364,7 +364,7 @@
color: var(--text-color);
cursor: pointer;
font-size: 0.8em;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.create-folder-form button.confirm {
@@ -404,7 +404,7 @@
.path-display {
padding: var(--space-1);
color: var(--text-color);
font-family: monospace;
font-family: var(--font-mono);
font-size: 0.9em;
line-height: 1.4;
white-space: pre-wrap;
@@ -453,7 +453,7 @@
right: 0;
bottom: 0;
background-color: var(--border-color);
transition: all 0.3s ease;
transition: var(--transition-slow);
border-radius: 18px;
}
@@ -465,9 +465,9 @@
left: 3px;
bottom: 3px;
background-color: white;
transition: all 0.3s ease;
transition: var(--transition-slow);
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 {
@@ -516,7 +516,7 @@
font-size: inherit;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
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);
@@ -577,13 +577,13 @@
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
background: var(--bg-color);
}
.file-option:hover {
border-color: var(--lora-accent);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-sm);
}
.file-option.selected {
@@ -669,3 +669,156 @@
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);
background-color: var(--lora-surface);
cursor: pointer;
transition: all 0.2s;
transition: var(--transition-base);
}
.example-option-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
border-color: var(--lora-accent);
}
@@ -68,5 +68,5 @@
/* Dark theme adjustments */
[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);
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
transition: var(--transition-base);
opacity: 0.7;
}
@@ -150,7 +150,7 @@
margin-left: 8px;
vertical-align: middle;
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;
letter-spacing: 0.5px;
}
@@ -164,7 +164,7 @@
/* Dark theme adjustments for 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 */
@@ -210,7 +210,7 @@
margin-left: 10px;
vertical-align: middle;
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 {
@@ -225,7 +225,7 @@
/* Dark theme adjustments */
[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 */
@@ -281,7 +281,7 @@
border-radius: var(--border-radius-sm);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
background-color: var(--lora-accent);
color: white;
text-decoration: none;
@@ -303,5 +303,5 @@
/* Dark theme adjustments */
[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;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.settings-toggle:hover {
@@ -81,7 +81,7 @@
font-weight: 500;
cursor: pointer;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
transition: var(--transition-base);
margin-bottom: 4px;
}
@@ -154,7 +154,7 @@
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 0.9em;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.settings-search-input:focus {
@@ -183,7 +183,7 @@
justify-content: center;
font-size: 0.7em;
opacity: 0.6;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.settings-search-clear:hover {
@@ -289,7 +289,7 @@
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
text-decoration: none;
position: relative;
}
@@ -335,7 +335,12 @@
}
}
/* API key input specific styles */
/* API key input — CSS masking (prevents Chrome password manager triggers) */
.api-key-masked {
-webkit-text-security: disc;
}
/* API key input specific styles (shared with proxy password) */
.api-key-input {
width: 100%; /* Take full width of parent */
position: relative;
@@ -345,7 +350,7 @@
.api-key-input input {
width: 100%;
padding: 6px 40px 6px 10px; /* Add left padding */
padding: 6px 40px 6px 10px; /* Right padding for eye button */
height: 32px;
box-sizing: border-box;
border-radius: var(--border-radius-xs);
@@ -353,6 +358,13 @@
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 0.95em;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.api-key-input input:focus {
border-color: var(--lora-accent);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
}
.api-key-input .toggle-visibility {
@@ -364,12 +376,98 @@
opacity: 0.6;
cursor: pointer;
padding: 4px 8px;
transition: opacity 0.2s ease;
}
.api-key-input .toggle-visibility:hover {
opacity: 1;
}
/* API key item — stack status/edit views vertically for smooth cross-fade */
.api-key-item .setting-control {
flex-direction: column;
align-items: flex-end;
}
/* API key status display (shown when not editing) */
.api-key-status {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
justify-content: flex-end;
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
max-height: 80px;
overflow: hidden;
}
.api-key-status.is-hidden {
opacity: 0;
max-height: 0;
transform: translateY(-4px);
pointer-events: none;
margin: 0;
}
.api-key-status-text {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.95em;
white-space: nowrap;
transition: color 0.2s ease;
}
/* Status color modifiers — replace inline styles */
.api-key-status--configured .fa-check-circle {
color: var(--lora-success);
}
.api-key-status--unconfigured .fa-times-circle {
color: var(--lora-error);
}
/* Utility classes for status icon colors (used by JS) */
.text-success {
color: var(--lora-success);
}
.text-error {
color: var(--lora-error);
}
/* API key inline edit container — flex row with input + buttons */
.api-key-edit {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
justify-content: flex-end;
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
max-height: 80px;
overflow: hidden;
}
.api-key-edit.is-hidden {
opacity: 0;
max-height: 0;
transform: translateY(-4px);
pointer-events: none;
margin: 0;
}
.api-key-edit .api-key-input {
flex: 1;
min-width: 0;
}
.api-key-edit .primary-btn,
.api-key-edit .secondary-btn {
height: 32px;
flex-shrink: 0;
white-space: nowrap;
}
/* Text input wrapper styles for consistent input styling */
.text-input-wrapper {
width: 100%;
@@ -582,7 +680,7 @@
}
.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);
padding: 2px 6px;
border-radius: var(--border-radius-xs);
@@ -614,7 +712,7 @@
border-bottom: 2px solid transparent;
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
opacity: 0.7;
}
@@ -813,6 +911,120 @@
outline: none;
}
/* Range Slider Control */
.range-control {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
justify-content: flex-end;
}
.range-control input[type="range"] {
--range-fill: 40%;
width: 120px;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: linear-gradient(
to right,
var(--lora-accent) 0%,
var(--lora-accent) var(--range-fill),
var(--border-color) var(--range-fill),
var(--border-color) 100%
);
border-radius: var(--radius-full);
outline: none;
cursor: pointer;
flex-shrink: 0;
transition: background 0.3s ease;
}
.range-control input[type="range"]:focus-visible {
outline: none;
}
.range-control input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--lora-accent);
cursor: pointer;
border: 2px solid var(--lora-surface);
box-shadow: var(--shadow-md);
transition: transform var(--transition-bounce), box-shadow 0.2s ease;
}
.range-control input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.2);
box-shadow: var(--shadow-md), 0 0 0 4px var(--color-accent-subtle);
}
.range-control input[type="range"]::-webkit-slider-thumb:active {
transform: scale(1.1);
box-shadow: var(--shadow-md), 0 0 0 6px var(--color-accent-subtle);
}
.range-control input[type="range"]:focus-visible::-webkit-slider-thumb {
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-accent-subtle);
}
.range-control input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--lora-accent);
cursor: pointer;
border: 2px solid var(--lora-surface);
box-shadow: var(--shadow-md);
transition: transform var(--transition-bounce), box-shadow 0.2s ease;
}
.range-control input[type="range"]::-moz-range-thumb:hover {
transform: scale(1.2);
box-shadow: var(--shadow-md), 0 0 0 4px var(--color-accent-subtle);
}
.range-control input[type="range"]::-moz-range-thumb:active {
transform: scale(1.1);
box-shadow: var(--shadow-md), 0 0 0 6px var(--color-accent-subtle);
}
.range-control input[type="range"]::-moz-range-track {
height: 6px;
border-radius: var(--radius-full);
background: var(--border-color);
}
.range-control .range-value {
min-width: 36px;
text-align: center;
font-size: 0.85em;
font-weight: 700;
color: var(--lora-accent);
font-variant-numeric: tabular-nums;
background: var(--surface-subtle);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
letter-spacing: 0.02em;
}
[data-theme="dark"] .range-control input[type="range"] {
background: linear-gradient(
to right,
var(--lora-accent) 0%,
var(--lora-accent) var(--range-fill),
rgba(255, 255, 255, 0.15) var(--range-fill),
rgba(255, 255, 255, 0.15) 100%
);
}
[data-theme="dark"] .range-control input[type="range"]::-moz-range-track {
background: rgba(255, 255, 255, 0.15);
}
/* Toggle Switch */
.toggle-switch {
position: relative;
@@ -927,19 +1139,19 @@ input:checked + .toggle-slider:before {
/* Path Template Settings Styles */
.template-preview {
background: rgba(0, 0, 0, 0.03);
background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
margin-top: 8px;
font-family: monospace;
font-family: var(--font-mono);
font-size: 0.9em;
color: var(--lora-accent);
display: none;
}
[data-theme="dark"] .template-preview {
background: rgba(255, 255, 255, 0.03);
background: var(--surface-subtle);
border: 1px solid var(--lora-border);
}
@@ -974,7 +1186,7 @@ input:checked + .toggle-slider:before {
border-radius: var(--border-radius-xs);
cursor: pointer;
font-size: 0.9em;
transition: all 0.2s;
transition: var(--transition-base);
height: 32px; /* Match other control heights */
}
@@ -1030,7 +1242,7 @@ input:checked + .toggle-slider:before {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
transition: var(--transition-base);
}
.remove-mapping-btn:hover {
@@ -1146,7 +1358,7 @@ input:checked + .toggle-slider:before {
color: white;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
font-family: var(--font-mono);
font-size: 1em;
font-weight: 500;
}
@@ -1175,7 +1387,7 @@ input:checked + .toggle-slider:before {
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 0.95em;
font-family: monospace;
font-family: var(--font-mono);
height: 24px;
transition: border-color 0.2s;
}
@@ -1277,7 +1489,7 @@ input:checked + .toggle-slider:before {
border-radius: 6px;
font-size: 14px;
font-weight: normal;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
font-family: var(--font-body);
white-space: normal;
max-width: 220px;
width: max-content;
@@ -1287,7 +1499,7 @@ input:checked + .toggle-slider:before {
pointer-events: none;
z-index: 10000;
line-height: 1.4;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
box-shadow: var(--shadow-elevated);
text-transform: none;
}
@@ -1309,7 +1521,7 @@ input:checked + .toggle-slider:before {
/* Dark theme adjustments for tooltip - Fully opaque */
[data-theme="dark"] .info-icon[data-tooltip]::after {
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 */
@@ -1361,7 +1573,7 @@ input:checked + .toggle-slider:before {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
transition: var(--transition-base);
flex-shrink: 0;
}

View File

@@ -58,8 +58,6 @@
}
.support-section {
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
margin-bottom: var(--space-2);
@@ -102,7 +100,7 @@
border-radius: var(--border-radius-sm);
text-decoration: none;
color: var(--text-color);
transition: all 0.2s ease;
transition: var(--transition-base);
}
.social-link:hover {
@@ -122,14 +120,14 @@
border-radius: var(--border-radius-sm);
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
transition: var(--transition-base);
margin-top: var(--space-1);
}
.kofi-button:hover {
background: #E04946;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
}
/* Patreon button style */
@@ -144,14 +142,14 @@
border-radius: var(--border-radius-sm);
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
transition: var(--transition-base);
margin-top: var(--space-1);
}
.patreon-button:hover {
background: #E04946;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
}
/* QR Code section styles */
@@ -191,7 +189,7 @@
max-width: 80%;
height: auto;
border-radius: var(--border-radius-sm);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
border: 1px solid var(--lora-border);
aspect-ratio: 1/1; /* Ensure proper aspect ratio for the square QR code */
}
@@ -214,7 +212,7 @@
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.support-toggle:hover {
@@ -258,12 +256,12 @@
color: white; /* Icon color changes to white on hover */
}
/* 增强hover状态的视觉反馈 */
/* Enhanced hover visual feedback */
.social-link:hover,
.update-link:hover,
.folder-item:hover {
border-color: var(--lora-accent);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
}
/* Supporters Section Styles */
@@ -349,14 +347,14 @@
border: 1px solid var(--border-color);
border-left: 3px solid var(--lora-accent);
border-radius: var(--border-radius-sm);
transition: all 0.2s ease;
transition: var(--transition-base);
cursor: default;
}
.supporter-special-card:hover {
border-color: var(--lora-accent);
border-left-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-header);
transform: translateX(4px);
}
@@ -441,7 +439,7 @@
font-size: 0.95em;
color: var(--text-color);
opacity: 0.85;
transition: all 0.2s ease;
transition: var(--transition-base);
white-space: nowrap;
cursor: default;
}

View File

@@ -123,7 +123,7 @@
}
.version-number {
font-family: monospace;
font-family: var(--font-mono);
font-weight: 600;
}
@@ -136,7 +136,7 @@
font-size: 0.85em;
opacity: 0.7;
margin-top: 4px;
font-family: monospace;
font-family: var(--font-mono);
color: var(--text-color);
}
@@ -160,7 +160,7 @@
border-radius: var(--border-radius-sm);
text-decoration: none;
color: var(--text-color);
transition: all 0.2s ease;
transition: var(--transition-base);
}
.update-link:hover {
@@ -171,7 +171,7 @@
/* Update progress styles */
.update-progress {
background: rgba(0, 0, 0, 0.03);
background: var(--surface-subtle);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
@@ -179,7 +179,7 @@
}
[data-theme="dark"] .update-progress {
background: rgba(255, 255, 255, 0.03);
background: var(--surface-subtle);
}
.progress-info {
@@ -234,8 +234,6 @@
/* Changelog section */
.changelog-section {
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: var(--border-radius-sm);
padding: var(--space-3);
}
@@ -334,7 +332,7 @@
background: rgba(0, 0, 0, 0.05);
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
font-family: var(--font-mono);
font-size: 0.9em;
}
@@ -429,7 +427,7 @@
}
[data-theme="dark"] .banner-history-item {
background: rgba(255, 255, 255, 0.03);
background: var(--surface-subtle);
}
.banner-history-title {

View File

@@ -7,7 +7,7 @@
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-toast);
z-index: calc(var(--z-modal) - 1);
transition: transform 0.3s ease, opacity 0.3s ease;
opacity: 0;
@@ -63,13 +63,21 @@
align-items: center;
justify-content: center;
opacity: 0.6;
transition: all 0.2s;
transition: var(--transition-base);
position: relative;
}
.icon-button:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.05);
.icon-button:hover,
.icon-button:focus-visible {
background: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
transform: scale(1.05);
outline: none;
}
[data-theme="dark"] .icon-button:hover,
[data-theme="dark"] .icon-button:focus-visible {
background: oklch(35% 0.02 256 / 0.98);
}
[data-theme="dark"] .icon-button:hover {

View File

@@ -9,6 +9,10 @@
position: relative;
}
#recipeTagsContainer {
width: 100%;
}
.recipe-modal-header h2 {
margin: 0 0 var(--space-1);
padding: var(--space-1);
@@ -55,7 +59,7 @@
padding: 4px 8px;
margin-left: 8px;
border-radius: var(--border-radius-xs);
transition: all 0.2s;
transition: var(--transition-base);
flex-shrink: 0;
display: flex;
align-items: center;
@@ -95,127 +99,11 @@
min-width: 0;
}
.content-editor.tags-editor input {
font-size: 0.9em;
}
/* 删除不再需要的按钮样式 */
/* Remove obsolete button styles */
.editor-actions {
display: none;
}
/* Special styling for tags content */
.tags-content {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
}
.tags-display {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
}
.no-tags {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.6;
font-style: italic;
}
/* Recipe Tags styles */
.recipe-tags-container {
position: relative;
margin-top: 0;
margin-bottom: 10px;
}
.recipe-tags-compact {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
}
.recipe-tag-compact {
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs);
padding: 2px 8px;
font-size: 0.75em;
color: var(--text-color);
white-space: nowrap;
}
[data-theme="dark"] .recipe-tag-compact {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
.recipe-tag-more {
background: var(--lora-accent);
color: var(--lora-text);
border-radius: var(--border-radius-xs);
padding: 2px 8px;
font-size: 0.75em;
cursor: pointer;
white-space: nowrap;
font-weight: 500;
}
.recipe-tags-tooltip {
position: absolute;
top: calc(100% + 8px);
left: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
padding: 10px 14px;
max-width: 400px;
z-index: 10;
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: all 0.2s ease;
pointer-events: none;
}
.recipe-tags-tooltip.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.tooltip-content {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 200px;
overflow-y: auto;
}
.tooltip-tag {
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs);
padding: 3px 8px;
font-size: 0.75em;
color: var(--text-color);
}
[data-theme="dark"] .tooltip-tag {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
#recipeModal .modal-content {
display: flex;
flex-direction: column;
@@ -251,19 +139,19 @@
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.03);
background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-weight: 500;
font-size: 0.9em;
transition: all 0.2s;
transition: var(--transition-base);
white-space: nowrap;
}
[data-theme="dark"] .recipe-source-url-btn {
background: rgba(255, 255, 255, 0.03);
background: var(--surface-subtle);
border: 1px solid var(--lora-border);
}
@@ -428,7 +316,7 @@
font-size: 0.85em;
cursor: pointer;
border: none;
transition: all 0.2s;
transition: var(--transition-base);
}
.source-url-cancel-btn {
@@ -548,7 +436,7 @@
cursor: pointer;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
transition: all 0.2s;
transition: var(--transition-base);
}
.copy-btn:hover,
@@ -705,7 +593,7 @@
cursor: pointer;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
transition: all 0.2s;
transition: var(--transition-base);
display: flex;
align-items: center;
justify-content: center;
@@ -725,7 +613,7 @@
cursor: pointer;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
transition: all 0.2s;
transition: var(--transition-base);
display: flex;
align-items: center;
justify-content: center;
@@ -797,7 +685,7 @@
.recipe-lora-item:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-header);
border-color: var(--lora-accent);
}
@@ -995,7 +883,7 @@
padding: 8px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-header);
z-index: var(--z-overlay);
width: max-content;
max-width: 200px;
@@ -1049,7 +937,7 @@
background: rgba(0, 0, 0, 0.1);
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
font-family: var(--font-mono);
font-size: 0.9em;
}
@@ -1086,7 +974,7 @@
font-size: 0.85em;
cursor: pointer;
border: none;
transition: all 0.2s;
transition: var(--transition-base);
}
.reconnect-cancel-btn {
@@ -1114,9 +1002,9 @@
color: #777;
}
/* 标题输入框特定的样式 */
/* Title input specific styles */
.title-input {
font-size: 1.2em !important; /* 调整为更合适的大小 */
font-size: 1.2em;
line-height: 1.2;
font-weight: 500;
}
@@ -1153,7 +1041,7 @@
max-height: 2.4em;
}
.recipe-tags-container {
#recipeTagsContainer {
margin-bottom: 6px;
}
@@ -1251,7 +1139,7 @@
padding: 8px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-header);
z-index: var(--z-overlay);
width: max-content;
max-width: 200px;

View File

@@ -7,7 +7,7 @@
gap: 4px;
}
/* 调整搜索框样式以匹配其他控件 */
/* Match search input styles to other controls */
.search-container input {
width: 100%;
padding: 6px 35px 6px 12px; /* Reduced right padding */
@@ -35,7 +35,7 @@
line-height: 1;
}
/* 修改清空按钮样式 */
/* Clear button styles */
.search-clear {
position: absolute;
right: 105px; /* Adjusted further left to avoid overlapping */
@@ -71,7 +71,7 @@
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
transition: background-color var(--transition-base), color var(--transition-base), border-color var(--transition-base);
flex-shrink: 0;
}
@@ -103,7 +103,7 @@
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
transition: background-color var(--transition-base), color var(--transition-base), border-color var(--transition-base);
flex-shrink: 0;
position: relative;
}
@@ -149,7 +149,7 @@
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
z-index: var(--z-overlay);
padding: 16px;
transition: transform 0.3s ease, opacity 0.3s ease;
@@ -243,7 +243,7 @@
color: var(--text-color);
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
user-select: none; /* Prevent text selection */
-webkit-user-select: none; /* For Safari */
-moz-user-select: none; /* For Firefox */
@@ -373,7 +373,7 @@
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
flex-shrink: 0;
}
@@ -402,7 +402,7 @@
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
z-index: var(--z-overlay);
padding: 16px;
transition: transform 0.3s ease, opacity 0.3s ease;
@@ -470,7 +470,7 @@
color: var(--text-color);
font-size: 13px; /* Slightly smaller font size */
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
user-select: none;
flex: 1;
text-align: center;
@@ -516,7 +516,7 @@
border-radius: var(--border-radius-sm);
background-color: var(--lora-surface);
border: 1px solid var(--border-color);
transition: all 0.2s ease;
transition: var(--transition-base);
cursor: pointer;
}
@@ -574,7 +574,7 @@
align-items: center;
justify-content: center;
font-size: 11px;
transition: all 0.2s ease;
transition: var(--transition-base);
margin-left: auto;
}
@@ -599,7 +599,7 @@
font-size: 14px;
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.25s ease;
transition: var(--transition-base);
}
/* Enabled state - visual cue that button is actionable */
@@ -726,7 +726,7 @@
cursor: pointer;
color: var(--text-color);
opacity: 0.7;
transition: all 0.2s ease;
transition: var(--transition-base);
font-weight: 500;
}

View File

@@ -78,7 +78,7 @@
color: var(--text-color);
white-space: normal;
word-break: break-all;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-header);
z-index: 100; /* Higher z-index to ensure it's above other elements */
min-width: 300px;
max-width: 300px;
@@ -107,7 +107,7 @@
color: var(--text-color);
white-space: normal;
word-break: break-all;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-header);
z-index: 100; /* Higher z-index to ensure it's above other elements */
min-width: 200px;
max-width: 300px;

View File

@@ -10,7 +10,7 @@
cursor: pointer;
padding: 2px 5px;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
transition: var(--transition-base);
}
.metadata-edit-btn:hover {
@@ -31,7 +31,7 @@
/* Edit Container */
.metadata-edit-container {
padding: var(--space-2);
background: rgba(0, 0, 0, 0.03);
background: var(--surface-hover);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
margin-top: var(--space-2);
@@ -42,7 +42,7 @@
}
[data-theme="dark"] .metadata-edit-container {
background: rgba(255, 255, 255, 0.03);
background: var(--surface-hover);
border: 1px solid var(--lora-border);
}
@@ -101,7 +101,7 @@
}
.metadata-item-dragging {
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
box-shadow: var(--shadow-dialog);
cursor: grabbing;
opacity: 0.95;
transition: none;
@@ -178,7 +178,7 @@ body.metadata-drag-active * {
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.metadata-edit-controls button:hover {
@@ -257,7 +257,7 @@ body.metadata-drag-active * {
border-radius: var(--border-radius-sm);
margin-top: 4px;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: var(--shadow-elevated);
overflow: hidden;
display: flex;
flex-direction: column;
@@ -299,7 +299,7 @@ body.metadata-drag-active * {
justify-content: space-between;
padding: 5px 10px;
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
border-radius: var(--border-radius-xs);
background: var(--lora-surface);
border: 1px solid var(--lora-border);

View File

@@ -8,69 +8,28 @@
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
z-index: var(--z-overlay);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-header);
display: flex;
flex-direction: column;
backdrop-filter: blur(8px);
/* Default state: hidden off-screen */
/* Default: hidden off-screen — prevents flash before JS runs */
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
.folder-sidebar.hidden-by-setting {
display: none !important;
}
/* Visible state */
.folder-sidebar.visible {
transform: translateX(0);
opacity: 1;
pointer-events: all;
}
/* Auto-hide states */
.folder-sidebar.auto-hide {
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
.folder-sidebar.auto-hide.hover-active {
transform: translateX(0);
opacity: 1;
pointer-events: all;
}
.folder-sidebar.collapsed {
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
/* Hover detection area for auto-hide */
.sidebar-hover-area {
position: fixed;
top: 68px;
left: 0;
width: 20px;
height: calc(100vh - 88px);
z-index: calc(var(--z-overlay) - 1);
background: transparent;
pointer-events: all;
}
.sidebar-hover-area.hidden-by-setting {
.folder-sidebar.hidden-by-setting {
display: none !important;
}
.sidebar-hover-area.disabled {
pointer-events: none;
}
.sidebar-header {
display: flex;
align-items: center;
@@ -83,7 +42,8 @@
flex-shrink: 0;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
position: relative;
}
.sidebar-header:hover {
@@ -120,7 +80,7 @@
padding: 4px;
border-radius: 4px;
opacity: 0.6;
transition: all 0.2s ease;
transition: var(--transition-base);
width: 24px;
height: 24px;
display: flex;
@@ -150,6 +110,75 @@
display: none;
}
/* ===== Sidebar Hidden Indicator (left edge) ===== */
.sidebar-hidden-indicator {
position: fixed;
left: 0;
top: 68px; /* Align with sidebar header */
z-index: var(--z-overlay);
width: 14px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--border-color);
opacity: 0.3;
border-radius: 0 4px 4px 0;
cursor: pointer;
transition: opacity 0.15s ease, background 0.15s ease;
}
.sidebar-hidden-indicator:hover {
opacity: 0.7;
background: var(--lora-accent);
}
.sidebar-hidden-indicator i {
font-size: 11px;
color: var(--text-muted);
transition: color 0.15s ease;
}
.sidebar-hidden-indicator:hover i {
color: white;
}
/* Subtle breathing animation for first-time discovery */
@keyframes sidebarBreathing {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.65; }
}
.sidebar-hidden-indicator.breathing {
animation: sidebarBreathing 2.5s ease-in-out infinite;
animation-delay: 0.5s;
}
.sidebar-hidden-indicator.breathing:hover {
animation: none;
}
.sidebar-hidden-indicator-tooltip {
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: 8px;
padding: 4px 10px;
background: var(--text-color);
color: var(--bg-color);
font-size: 0.8em;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.sidebar-hidden-indicator:hover .sidebar-hidden-indicator-tooltip {
opacity: 1;
}
.sidebar-content {
flex: 1;
overflow: hidden;
@@ -174,7 +203,7 @@
align-items: center;
padding: 8px 16px;
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
font-size: 0.85em;
border-left: 3px solid transparent;
color: var(--text-color);
@@ -298,7 +327,7 @@
padding: 4px 8px;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
color: var(--text-muted);
position: relative;
}
@@ -331,7 +360,7 @@
margin-left: 6px;
color: inherit;
opacity: 0.6;
transition: all 0.2s ease;
transition: var(--transition-base);
pointer-events: none;
font-size: 0.9em;
}
@@ -364,7 +393,7 @@
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: 0 3px 8px rgba(0,0,0,0.15);
box-shadow: var(--shadow-lg);
z-index: calc(var(--z-overlay) + 20);
overflow-y: auto;
max-height: 450px;
@@ -382,7 +411,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.breadcrumb-dropdown-item:hover {
@@ -406,7 +435,7 @@
align-items: center;
padding: 8px 16px;
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
font-size: 0.85em;
border-left: 3px solid transparent;
color: var(--text-color);
@@ -515,7 +544,7 @@
opacity: 0.3;
}
/* Responsive Design */
/* Responsive Design — Mobile: overlay when shown */
@media (max-width: 1024px) {
.folder-sidebar {
top: 68px;
@@ -526,12 +555,8 @@
z-index: calc(var(--z-overlay) + 10);
}
.folder-sidebar.collapsed {
transform: translateX(-100%);
}
/* Mobile overlay */
.folder-sidebar:not(.collapsed)::before {
/* Mobile overlay when sidebar is shown */
.folder-sidebar.visible::before {
content: '';
position: fixed;
top: 0;
@@ -614,7 +639,7 @@
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08);
opacity: 0;
transform: translateY(10px);
transition: all 0.2s ease;
transition: var(--transition-base);
pointer-events: none;
z-index: 10;
}
@@ -649,7 +674,7 @@
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
box-shadow: var(--shadow-lg);
z-index: 20;
animation: slideUp 0.2s ease;
}
@@ -685,7 +710,7 @@
color: var(--text-color);
font-size: 0.85em;
outline: none;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.sidebar-create-folder-input:focus {
@@ -702,24 +727,30 @@
border: none;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
background: transparent;
color: var(--text-muted);
}
.sidebar-create-folder-btn:hover {
.sidebar-create-folder-btn:hover,
.sidebar-create-folder-btn:focus-visible {
background: var(--lora-surface);
color: var(--text-color);
outline: none;
}
.sidebar-create-folder-confirm:hover {
.sidebar-create-folder-confirm:hover,
.sidebar-create-folder-confirm:focus-visible {
background: oklch(from var(--success-color) l c h / 0.15);
color: var(--success-color);
outline: none;
}
.sidebar-create-folder-cancel:hover {
.sidebar-create-folder-cancel:hover,
.sidebar-create-folder-cancel:focus-visible {
background: oklch(from var(--error-color) l c h / 0.15);
color: var(--error-color);
outline: none;
}
.sidebar-create-folder-hint {

View File

@@ -17,13 +17,13 @@
border-radius: var(--border-radius-base);
padding: var(--space-2);
text-align: center;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: var(--transition-slow);
box-shadow: var(--shadow-sm);
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: var(--shadow-elevated);
}
.metric-card .metric-icon {
@@ -95,7 +95,7 @@
border: none;
padding: var(--space-2) var(--space-3);
cursor: pointer;
transition: all 0.3s ease;
transition: var(--transition-slow);
color: var(--text-color);
border-bottom: 3px solid transparent;
white-space: nowrap;
@@ -208,7 +208,7 @@
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
transition: var(--transition-base);
}
.model-item:hover {
@@ -270,7 +270,7 @@
border-radius: var(--border-radius-xs);
font-size: 0.8rem;
border: 1px solid oklch(var(--lora-accent) / 0.2);
transition: all 0.2s ease;
transition: var(--transition-base);
cursor: pointer;
}
@@ -349,12 +349,12 @@
padding: var(--space-2);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
transition: all 0.3s ease;
transition: var(--transition-slow);
}
.insight-card:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
}
.insight-card.type-success {
@@ -428,7 +428,7 @@
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
transition: var(--transition-base);
}
.recommendation-item:hover {
@@ -534,9 +534,9 @@
}
[data-theme="dark"] .metric-card {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
box-shadow: var(--shadow-md);
}
[data-theme="dark"] .metric-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
box-shadow: var(--shadow-dark-lg);
}

View File

@@ -15,18 +15,18 @@
/* Toast Notifications */
.toast {
position: fixed;
top: 20px; /* 改为从顶部显示 */
right: 20px; /* 改为右对齐 */
left: auto; /* 移除左对齐 */
transform: translateX(120%); /* 初始位置在屏幕右侧外 */
min-width: 300px; /* 设置最小宽度 */
max-width: 400px; /* 设置最大宽度 */
top: 20px;
right: 20px;
left: auto;
transform: translateX(120%);
min-width: 300px;
max-width: 400px;
background: var(--lora-surface);
color: var(--text-color);
padding: 12px 16px;
border-radius: var(--border-radius-sm);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
z-index: calc(var(--z-overlay) + 10); /* 让toast显示在最上层 */
box-shadow: var(--shadow-toast);
z-index: calc(var(--z-overlay) + 10);
opacity: 0;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
@@ -36,11 +36,10 @@
}
.toast.show {
transform: translateX(0); /* 显示时滑入到正确位置 */
transform: translateX(0);
opacity: 1;
}
/* 添加图标容器 */
.toast::before {
content: '';
width: 20px;
@@ -51,7 +50,7 @@
background-size: contain;
}
/* 不同类型的toast样式 */
/* Toast type variants */
.toast-success {
border-left: 4px solid oklch(65% 0.2 142);
}
@@ -76,15 +75,15 @@
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%232196f3'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z'/%3E%3C/svg%3E");
}
/* 多个toast堆叠显示 */
/* Stacked toast spacing */
.toast + .toast {
margin-top: 10px;
}
/* 响应式调整 */
/* Responsive adjustments */
@media (max-width: 768px) {
.toast {
width: calc(100% - 40px); /* 左右各留20px间距 */
width: calc(100% - 40px);
max-width: none;
right: 20px;
}

View File

@@ -10,7 +10,7 @@
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 15px;
padding: 0 var(--space-2);
position: relative;
z-index: var(--z-base);
}
@@ -22,7 +22,7 @@
z-index: calc(var(--z-header) - 1);
background: var(--bg-color);
padding: var(--space-1) 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
box-shadow: var(--shadow-xs);
}
/* Responsive container for larger screens */
@@ -78,21 +78,23 @@
background: var(--card-bg);
color: var(--text-color);
font-size: 0.85em;
transition: all 0.2s ease;
transition: var(--transition-base);
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
box-shadow: var(--shadow-xs);
}
.control-group button:hover {
.control-group button:hover,
.control-group button:focus-visible {
border-color: var(--lora-accent);
background: var(--bg-color);
transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-lg);
outline: none;
}
.control-group button:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
box-shadow: var(--shadow-xs);
}
.control-group button i {
@@ -100,7 +102,8 @@
transition: opacity 0.2s ease;
}
.control-group button:hover i {
.control-group button:hover i,
.control-group button:focus-visible i {
opacity: 1;
}
@@ -131,7 +134,7 @@
.control-group button.favorite-filter i {
margin-right: 4px;
color: #ffc107;
color: var(--favorite-color);
}
.control-group button.update-filter i {
@@ -183,7 +186,7 @@
color: var(--shortcut-text);
vertical-align: middle;
opacity: 0.8;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.control-group button:hover .shortcut-key {
@@ -219,8 +222,8 @@
background-position: right 6px center;
background-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
transition: var(--transition-base);
box-shadow: var(--shadow-xs);
}
/* Style for optgroups */
@@ -252,7 +255,7 @@
border-color: var(--lora-accent);
background-color: var(--bg-color);
transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-lg);
}
.control-group select:focus {
@@ -292,9 +295,9 @@
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: all 0.3s ease;
transition: var(--transition-slow);
z-index: var(--z-overlay);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-sm);
}
.back-to-top.visible {
@@ -307,7 +310,7 @@
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
box-shadow: var(--shadow-md);
}
/* Prevent text selection in control and header areas */
@@ -336,7 +339,7 @@
.dropdown-main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 1px solid rgba(0, 0, 0, 0.1);
border-right: 1px solid var(--border-color);
}
.dropdown-toggle {
@@ -364,7 +367,7 @@
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
box-shadow: var(--shadow-xl);
}
.dropdown-group.active .dropdown-menu {

View File

@@ -24,11 +24,11 @@
border-radius: var(--border-radius-base);
z-index: calc(var(--z-overlay) + 1);
pointer-events: none;
transition: all 0.3s ease;
transition: var(--transition-slow);
/* Add glow effect */
box-shadow:
0 0 0 2px rgba(24, 144, 255, 0.3),
0 0 20px rgba(24, 144, 255, 0.2),
0 0 0 2px color-mix(in oklch, var(--color-accent) 30%, transparent),
0 0 20px color-mix(in oklch, var(--color-accent) 20%, transparent),
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
@@ -53,7 +53,7 @@
min-width: 320px;
max-width: 400px;
z-index: calc(var(--z-overlay) + 3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
box-shadow: var(--shadow-2xl);
backdrop-filter: blur(10px);
}
@@ -98,7 +98,7 @@
color: var(--text-color);
cursor: pointer;
font-size: 0.9em;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.onboarding-btn:hover {
@@ -138,7 +138,7 @@
padding: var(--space-3);
min-width: 510px;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
box-shadow: var(--shadow-dark-lg);
backdrop-filter: blur(10px);
}
@@ -167,7 +167,7 @@
border-radius: var(--border-radius-sm);
background: var(--card-bg);
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
display: flex;
flex-direction: column;
align-items: center;
@@ -221,14 +221,14 @@
@keyframes onboarding-pulse {
0%, 100% {
box-shadow:
0 0 0 2px rgba(24, 144, 255, 0.4),
0 0 20px rgba(24, 144, 255, 0.3),
0 0 0 2px color-mix(in oklch, var(--color-accent) 40%, transparent),
0 0 20px color-mix(in oklch, var(--color-accent) 30%, transparent),
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
50% {
box-shadow:
0 0 0 4px rgba(24, 144, 255, 0.6),
0 0 30px rgba(24, 144, 255, 0.4),
0 0 0 4px color-mix(in oklch, var(--color-accent) 60%, transparent),
0 0 30px color-mix(in oklch, var(--color-accent) 40%, transparent),
inset 0 0 0 1px rgba(255, 255, 255, 0.2);
}
}

View File

@@ -36,10 +36,11 @@
@import 'components/initialization.css';
@import 'components/progress-panel.css';
@import 'components/duplicates.css'; /* Add duplicates component */
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
@import 'components/statistics.css'; /* Add statistics component */
@import 'components/sidebar.css'; /* Add sidebar component */
@import 'components/media-viewer.css';
@import 'components/metadata-refresh-result.css';
.initialization-notice {
display: flex;
@@ -54,7 +55,7 @@
text-align: center;
}
/* 使用已有的loading-spinner样式 */
/* Reuse existing loading-spinner styles */
.initialization-notice .loading-spinner {
margin-bottom: var(--space-2);
}

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