Compare commits

...

79 Commits

Author SHA1 Message Date
Will Miao fe90f7f9b1 feat(ui): add searchable base model dropdown with filename inference in model modal
Replace native <select> with a searchable dropdown that:
- Filters options as the user types
- Shows filename-inferred suggestions at the top in a "Suggested" section
- Supports keyboard navigation (ArrowUp/Down/Enter/Escape)
- Allows typing custom values not in the list
- Removes dead .base-model-selector CSS

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Includes:
- Backend: dedup logic, route param parsing, settings default
- Frontend: API param, SettingsManager wiring, toggle UI
- i18n: translations for all 10 locales
- Tests: unit test covering dedup on/off and standalone items
2026-06-21 08:48:42 +08:00
Will Miao 2b8e7c7504 fix(tests): update recipes page tests for unified controls template
- Inject #customFilterIndicator DOM in beforeEach (raw template
  renderer doesn't process Jinja2 {% include %} tags)
- Fix selector from #customFilterText to .customFilterText
2026-06-20 06:55:47 +08:00
Will Miao 6816d75933 refactor(recipes): unify controls and breadcrumb UI with model pages
- Replace inline controls+breadcrumb in recipes.html with shared includes
- Add page_id conditionals in controls.html to adapt buttons per page type
- Unify customFilterText selector to class-based in recipes.js
- Add [data-action="find-duplicates"] event listener for unified button
- Fix i18n keys to use recipes-specific translations on recipes page
2026-06-19 22:41:50 +08:00
willmiao b58abbad7c docs: auto-update supporters list in README 2026-06-19 10:31:18 +00:00
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
151 changed files with 10612 additions and 2751 deletions
+5
View File
@@ -7,6 +7,10 @@ py/run_test.py
.vscode/ .vscode/
cache/ cache/
civitai/ civitai/
stats/
wildcards/
backups/
logs/
node_modules/ node_modules/
coverage/ coverage/
.coverage .coverage
@@ -19,6 +23,7 @@ model_cache/
.codex .codex
.omo .omo
reasonix.toml reasonix.toml
.reasonix/
.codegraph/ .codegraph/
# Vue widgets development cache (but keep build output) # Vue widgets development cache (but keep build output)
+2 -2
View File
File diff suppressed because one or more lines are too long
+306 -285
View File
@@ -11,42 +11,62 @@
"Insomnia Art Designs", "Insomnia Art Designs",
"2018cfh", "2018cfh",
"Arlecchino Shion", "Arlecchino Shion",
"Charles Blakemore",
"Rob Williams", "Rob Williams",
"W+K+White", "W+K+White",
"$MetaSamsara", "$MetaSamsara",
"wackop", "wackop",
"Phil", "Phil",
"Carl G.", "Carl G.",
"Charles Blakemore",
"stone9k", "stone9k",
"Rosenthal",
"Francisco Tatis",
"JongWon Han",
"FreelancerZ",
"Polymorphic Indeterminate",
"Skalabananen",
"Marc Whiffen",
"Birdy",
"itismyelement", "itismyelement",
"Mozzel", "Mozzel",
"Gingko Biloba", "Gingko Biloba",
"Kiba", "Kiba",
"Reno Lam",
"onesecondinosaur", "onesecondinosaur",
"sig",
"Christian Byrne", "Christian Byrne",
"DM", "DM",
"Sen314", "Sen314",
"Estragon", "Estragon",
"Rosenthal", "J\\B/ 8r0wns0n",
"ClockDaemon", "ClockDaemon",
"Francisco Tatis", "KD",
"Omnidex",
"Tyler Trebuchon",
"Release Cabrakan",
"Tobi_Swagg", "Tobi_Swagg",
"SG", "SG",
"James Dooley",
"zenbound",
"Buzzard",
"jmack", "jmack",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"Mark Corneglio",
"Ricky Carter", "Ricky Carter",
"JongWon Han", "James Todd",
"Steven Pfeiffer",
"VantAI", "VantAI",
"レプサイ", "レプサイ",
"Lisster",
"Michael Wong", "Michael Wong",
"runte3221", "runte3221",
"Illrigger", "Illrigger",
"Tom Corrigan", "Tom Corrigan",
"JackieWang", "JackieWang",
"FreelancerZ",
"fnkylove", "fnkylove",
"Yushio",
"Vik71it",
"Echo", "Echo",
"Lilleman", "Lilleman",
"Robert Stacey", "Robert Stacey",
@@ -54,174 +74,161 @@
"Edgar Tejeda", "Edgar Tejeda",
"Fraser Cross", "Fraser Cross",
"Liam MacDougal", "Liam MacDougal",
"Polymorphic Indeterminate",
"Sterilized", "Sterilized",
"BadassArabianMofo",
"JORGE+LUIZ+HUSSNI+MESSIAS", "JORGE+LUIZ+HUSSNI+MESSIAS",
"Marc Whiffen",
"Skalabananen",
"Birdy",
"quarz", "quarz",
"Reno Lam", "Greg",
"jean jahren",
"JSST", "JSST",
"sig",
"J\\B/ 8r0wns0n",
"Snaggwort", "Snaggwort",
"lmsupporter",
"Takkan", "Takkan",
"wfpearl",
"Matt+J", "Matt+J",
"Baekdoosixt", "Baekdoosixt",
"Jonathan Ross", "Jonathan Ross",
"KD", "Jack B Nimble",
"Omnidex",
"Nazono_hito", "Nazono_hito",
"Melville Parrish", "Melville Parrish",
"daniel dove", "daniel dove",
"Lustre", "Lustre",
"Tyler Trebuchon",
"Release Cabrakan",
"JW Sin", "JW Sin",
"Alex", "Alex",
"bh",
"carozzz", "carozzz",
"Marlon Daniels", "Marlon Daniels",
"James Dooley", "Starkselle",
"zenbound", "Aaron Bleuer",
"Buzzard", "LacesOut!",
"greebles",
"Adam Shaw", "Adam Shaw",
"Mark Corneglio",
"SarcasticHashtag", "SarcasticHashtag",
"Anthony Rizzo", "Anthony Rizzo",
"iamresist", "iamresist",
"M Postkasse",
"RedrockVP", "RedrockVP",
"Wolffen", "Wolffen",
"James Todd",
"Wicked Choices by ASLPro3D", "Wicked Choices by ASLPro3D",
"Jacob Hoehler",
"FinalyFree", "FinalyFree",
"Steven Pfeiffer", "Weasyl",
"Timmy", "Timmy",
"Johnny", "Johnny",
"Cory Paza",
"Tak", "Tak",
"Lisster",
"Big Red", "Big Red",
"whudunit", "whudunit",
"Luc Job", "Luc Job",
"dl0901dm", "dl0901dm",
"Philip Hempel",
"corde", "corde",
"Nick Walker", "nwalker94",
"Yushio",
"Vik71it",
"Bishoujoker", "Bishoujoker",
"aai",
"Todd Keck", "Todd Keck",
"Briton Heilbrun", "Briton Heilbrun",
"Tori", "Tori",
"wildnut", "wildnut",
"Aleksander Wujczyk", "Aleksander Wujczyk",
"AM Kuro", "AM Kuro",
"BadassArabianMofo",
"Pascal Dahle", "Pascal Dahle",
"Greg", "Sangheili460",
"MagnaInsomnia",
"Akira_HentAI", "Akira_HentAI",
"lmsupporter", "Karl P.",
"andrew.tappan", "andrew.tappan",
"N/A",
"The Spawn",
"graysock",
"Greenmoustache", "Greenmoustache",
"zounic", "zounic",
"wfpearl", "fancypants",
"Eldithor", "Digital",
"Jack B Nimble",
"JaxMax", "JaxMax",
"contrite831", "contrite831",
"bh",
"Jwk0205", "Jwk0205",
"Starkselle", "Bro Xie",
"batblue",
"carey6409",
"Olive", "Olive",
"Aaron Bleuer", "太郎 ゲーム",
"LacesOut!",
"greebles",
"Some Guy Named Barry", "Some Guy Named Barry",
"M Postkasse", "AELOX",
"Gooohokrbe", "Gooohokrbe",
"Nicfit23",
"wamekukyouzin",
"OldBones", "OldBones",
"Jacob Hoehler", "drum matthieu",
"Dogmaster",
"Matt Wenzel", "Matt Wenzel",
"Weasyl",
"Lex Song", "Lex Song",
"Cory Paza", "Christopher Michel",
"Gonzalo Andre Allendes Lopez", "Gonzalo Andre Allendes Lopez",
"Zach Gonser", "Zach Gonser",
"Serge Bekenkamp", "Serge Bekenkamp",
"Jimmy Ledbetter", "Jimmy Ledbetter",
"Philip Hempel", "LeoZero",
"Antonio Pontes",
"nahinahi9",
"Dustin Chen",
"dan", "dan",
"aai", "Mouthlessman",
"otaku fra", "otaku fra",
"jean jahren",
"MiraiKuriyamaSy", "MiraiKuriyamaSy",
"Ran C", "Ran C",
"ViperC", "ViperC",
"Penfore", "Penfore",
"Sangheili460",
"MagnaInsomnia",
"Karl P.",
"Gordon Cole", "Gordon Cole",
"Adam Taylor", "Adam Taylor",
"AbstractAss", "AbstractAss",
"Weird_With_A_Beard", "Weird_With_A_Beard",
"N/A",
"The Spawn",
"graysock",
"Pozadine1", "Pozadine1",
"Qarob", "Qarob",
"AIGooner", "AIGooner",
"Luc", "Luc",
"ProtonPrince", "ProtonPrince",
"DiffDuck", "DiffDuck",
"Jackthemind",
"fancypants",
"Joboshy",
"Digital",
"takyamtom",
"Bohemian Corporal",
"Dan",
"Bro Xie",
"yer fey",
"batblue",
"carey6409",
"太郎 ゲーム",
"Roslynd",
"jinxedx",
"Cosmosis",
"AELOX",
"Dankin",
"Nicfit23",
"FloPro4Sho",
"Cristian Vazquez",
"wamekukyouzin",
"drum matthieu",
"Dogmaster",
"Frank Nitty",
"Magic Noob",
"Christopher Michel",
"DougPeterson",
"LeoZero",
"Antonio Pontes",
"ApathyJones",
"Bruce",
"Julian V",
"Steven Owens",
"nahinahi9",
"Kevin John Duck",
"Dustin Chen",
"Blackfish95",
"Mouthlessman",
"Paul Kroll",
"Bas Imagineer",
"John Statham",
"yuxz69",
"esthe",
"decoy",
"elu3199", "elu3199",
"Hasturkun", "Hasturkun",
"Jon Sandman", "Jon Sandman",
"Ubivis", "Ubivis",
"CloudValley", "CloudValley",
"linnfrey",
"Jackthemind",
"griffin+dahlberg",
"Joboshy",
"takyamtom",
"Bohemian Corporal",
"Dan",
"yer fey",
"Error_Rule34_Not_found",
"Roslynd",
"jinxedx",
"Neco28",
"Cosmosis",
"David Ortega",
"Dankin",
"FloPro4Sho",
"Cristian Vazquez",
"Frank Nitty",
"Magic Noob",
"DougPeterson",
"ApathyJones",
"Jeff",
"Bruce",
"Steven Owens",
"Kevin John Duck",
"Kevin Christopher",
"Blackfish95",
"dd",
"Paul Kroll",
"Bas Imagineer",
"John Statham",
"yuxz69",
"esthe",
"AlexDuKaNa",
"decoy",
"thesoftwaredruid", "thesoftwaredruid",
"wundershark", "wundershark",
"mr_dinosaur", "mr_dinosaur",
@@ -229,53 +236,11 @@
"Ray Wing", "Ray Wing",
"Ranzitho", "Ranzitho",
"Gus", "Gus",
"地獄の禄",
"MJG", "MJG",
"David LaVallee", "David LaVallee",
"linnfrey",
"IamAyam",
"skaterb949",
"Josef Lanzl",
"Nerezza",
"sanborondon",
"confiscated Zyra",
"Error_Rule34_Not_found",
"Taylor Funk",
"aezin",
"jcay015",
"Gerald Welly",
"Erik Lopez",
"Mateo Curić",
"Tee Gee",
"Geolog",
"tarek helmi",
"Neco28",
"Eris3D",
"Max Marklund",
"David Ortega",
"Pronredn",
"a _",
"Jeff",
"lh qwe",
"James Coleman",
"conner",
"Kevin Christopher",
"Chad Idk",
"dd",
"Princess Bright Eyes",
"Dušan Ryban",
"Felipe dos Santos",
"Sam",
"sjon kreutz",
"Douglas Gaspar",
"Metryman55",
"AlexDuKaNa",
"George",
"dw",
"地獄の禄",
"ae", "ae",
"Tr4shP4nda", "Tr4shP4nda",
"Gamalonia",
"WRL_SPR",
"capn", "capn",
"Joseph", "Joseph",
"Mirko Katzula", "Mirko Katzula",
@@ -283,64 +248,64 @@
"Piccio08", "Piccio08",
"kumakichi", "kumakichi",
"cppbel", "cppbel",
"IamAyam",
"jeaness",
"Brian M",
"Josef Lanzl",
"Nerezza",
"sanborondon",
"confiscated Zyra",
"Taylor Funk",
"aezin",
"Thought2Form",
"jcay015",
"Gerald Welly",
"Kevin Picco",
"Erik Lopez",
"Mateo Curić",
"Tee Gee",
"Geolog",
"tarek helmi",
"Eris3D",
"Max Marklund",
"m",
"Pierce McBride",
"Pronredn",
"Mikko Hemilä",
"Jamie Ogletree",
"a _",
"lh qwe",
"James Coleman",
"Martial",
"conner",
"Ouro Boros",
"Chad Idk",
"Princess Bright Eyes",
"Yuji Kaneko",
"Dušan Ryban",
"Felipe dos Santos",
"Rops Alot",
"Sam",
"sjon kreutz",
"Ace Ventura",
"Douglas Gaspar",
"Metryman55",
"George",
"dw",
"Gamalonia",
"WRL_SPR",
"momokai",
"Moon Knight", "Moon Knight",
"몽타주", "몽타주",
"Kland", "Kland",
"Hailshem", "Hailshem",
"kudari", "kudari",
"Naomi Hale Danchi", "Naomi Hale Danchi",
"ken",
"epicgamer0020690", "epicgamer0020690",
"Joshua Porrata", "Joshua Porrata",
"SuBu",
"Richard",
"奚明 刘",
"Andrew",
"Brian M",
"Robert Wegemund",
"Littlehuggy",
"준희 김",
"Brian Buie",
"Thought2Form",
"Kevin Picco",
"Sadlip",
"Tomohiro Baba",
"m",
"Noora",
"Pierce McBride",
"Joshua Gray",
"Mattssn",
"Mikko Hemilä",
"Jacob McDaniel",
"Jamie Ogletree",
"Temikus",
"Artokun",
"Michael Taylor",
"Martial",
"Michael Anthony Scott",
"Emil Andersson",
"Ouro Boros",
"Atilla Berke Pekduyar",
"Steam Steam",
"CryptoTraderJK",
"Decx _",
"Yuji Kaneko",
"Davaitamin",
"Rops Alot",
"tedcor",
"Fotek Design",
"Ace Ventura",
"四糸凜音",
"Nihongasuki",
"LarsesFPC",
"MadSpin",
"inbijiburu",
"Nick “Loadstone” D",
"momokai",
"starbugx",
"dc7431",
"ken",
"Crocket",
"keemun", "keemun",
"SuBu",
"RedPIXel", "RedPIXel",
"Wind", "Wind",
"Nexus", "Nexus",
@@ -353,6 +318,51 @@
"JohnDoe42054", "JohnDoe42054",
"BillyHill", "BillyHill",
"emyth", "emyth",
"gzmzmvp",
"Richard",
"奚明 刘",
"Andrew",
"Robert Wegemund",
"Littlehuggy",
"Gregory Kozhemiak",
"준희 김",
"Brian Buie",
"Sadlip",
"Eric Whitney",
"Joey Callahan",
"Ivan Tadic",
"Tomohiro Baba",
"Mike Simone",
"Noora",
"Joshua Gray",
"Mattssn",
"Morgandel",
"Jacob McDaniel",
"X",
"Sloan Steddy",
"Temikus",
"Artokun",
"Michael Taylor",
"Derek Baker",
"Michael Anthony Scott",
"Emil Andersson",
"Atilla Berke Pekduyar",
"Steam Steam",
"CryptoTraderJK",
"Decx _",
"Davaitamin",
"tedcor",
"Fotek Design",
"四糸凜音",
"Nihongasuki",
"LarsesFPC",
"MadSpin",
"FrxzenSnxw",
"inbijiburu",
"Nick “Loadstone” D",
"starbugx",
"dc7431",
"Crocket",
"chriphost", "chriphost",
"KitKatM", "KitKatM",
"socrasteeze", "socrasteeze",
@@ -370,59 +380,66 @@
"Vir", "Vir",
"Skyfire83", "Skyfire83",
"Adam Rinehart", "Adam Rinehart",
"gzmzmvp", "Pitpe11",
"Gregory Kozhemiak", "TheD1rtyD03",
"moonpetal",
"g9p0o",
"TheHolySheep",
"Monte Won",
"SpringBootisTrash",
"carsten",
"ikok",
"Wolfe7D1",
"Draven T", "Draven T",
"mrjuan", "elleshar666",
"Eric Whitney", "ACTUALLY_the_Real_Willem_Dafoe",
"Joey Callahan",
"Aquatic Coffee", "Aquatic Coffee",
"Ivan Tadic", "Kauffy",
"Mike Simone",
"John J Linehan", "John J Linehan",
"ethanfel", "ethanfel",
"Elliot E", "Elliot E",
"Morgandel",
"Theerat Jiramate", "Theerat Jiramate",
"Focuschannel", "Focuschannel",
"Edward Kennedy",
"Noah", "Noah",
"X", "Vane Holzer",
"Sloan Steddy", "psytrax",
"hexxish", "hexxish",
"Derek Baker",
"Anthony Faxlandez", "Anthony Faxlandez",
"battu", "battu",
"notedfakes",
"Nathan", "Nathan",
"NICHOLAS BAXLEY", "NICHOLAS BAXLEY",
"Michael Scott",
"Pat Hen", "Pat Hen",
"Xeeosat", "Xeeosat",
"Saya",
"Ed Wang", "Ed Wang",
"Jordan Shaw", "Jordan Shaw",
"Wes Sims",
"g unit", "g unit",
"Srdb", "Srdb",
"Filippo Ferrari",
"JC", "JC",
"Prompt Pirate", "Prompt Pirate",
"uwutismxd", "uwutismxd",
"FrxzenSnxw",
"zenobeus", "zenobeus",
"ryoma", "ryoma",
"Whitepinetrader",
"Stryker", "Stryker",
"Ginnie", "Ginnie",
"Raku", "Raku",
"smart.edge5178", "smart.edge5178",
"Menard", "Menard",
"Pitpe11",
"TheD1rtyD03",
"moonpetal",
"SomeDude", "SomeDude",
"g9p0o",
"Pkrsky", "Pkrsky",
"TheHolySheep", "nanana",
"raf8osz", "raf8osz",
"Monte Won", "FeralOpticsAI",
"SpringBootisTrash", "Pavlaki",
"carsten", "Doug+Rintoul",
"ikok", "Noor",
"Yorunai",
"quantenmecha", "quantenmecha",
"Jason+Nash", "Jason+Nash",
"DarkRoast", "DarkRoast",
@@ -436,38 +453,35 @@
"cocona", "cocona",
"ElitaSSJ4", "ElitaSSJ4",
"David Schenck", "David Schenck",
"Wolfe7D1",
"blikkies", "blikkies",
"Chris", "Chris",
"Time Valentine", "Time Valentine",
"elleshar666",
"Shock Shockor", "Shock Shockor",
"ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч", "Михал Михалыч",
"Matt",
"Goldwaters", "Goldwaters",
"Kauffy",
"Zude", "Zude",
"Frogmilk",
"SPJ",
"Kyler", "Kyler",
"Edward Kennedy", "Bryan Rutkowski",
"Justin Blaylock", "Justin Blaylock",
"aRtFuL_DodGeR", "aRtFuL_DodGeR",
"Nick Kage", "Nick Kage",
"Vane Holzer",
"psytrax",
"Cyrus Fett", "Cyrus Fett",
"Xenon Xue", "Xenon Xue",
"notedfakes", "Edward Ten Eyck",
"Billy Gladky", "Billy Gladky",
"Michael Scott",
"Probis", "Probis",
"Solixer", "Solixer",
"Wes Sims",
"ItsGeneralButtNaked", "ItsGeneralButtNaked",
"Donor4115", "Donor4115",
"jinksta187",
"Distortik", "Distortik",
"Filippo Ferrari", "Manu Thetug",
"Karlanx",
"operationancut",
"Youguang", "Youguang",
"Saya",
"andrewzpong", "andrewzpong",
"BossGame", "BossGame",
"lrdchs", "lrdchs",
@@ -476,9 +490,19 @@
"AIVORY3D", "AIVORY3D",
"Kevinj", "Kevinj",
"Mitchell Robson", "Mitchell Robson",
"Whitepinetrader",
"POPPIN", "POPPIN",
"nanana", "YassineKhaled",
"Y",
"MatteKey",
"Flob",
"ShiroSenpai",
"Inkognito",
"G",
"Tan+Huynh",
"Bob+Barker",
"D",
"Dark_Pest",
"Eldithor",
"Alex", "Alex",
"Karru", "Karru",
"ChaChanoKo", "ChaChanoKo",
@@ -491,38 +515,36 @@
"g", "g",
"J", "J",
"Alan+Cano", "Alan+Cano",
"FeralOpticsAI",
"Pavlaki",
"Doug+Rintoul",
"Noor",
"Yorunai",
"BillyBoy84", "BillyBoy84",
"Buecyb99", "Buecyb99",
"Welkor", "Welkor",
"John Martin", "John Martin",
"Ink Temptation",
"JBsuede", "JBsuede",
"moranqianlong", "moranqianlong",
"Kalli Core", "Kalli Core",
"Ronan Delevacq",
"Christian Schäfer", "Christian Schäfer",
"りん あめ", "りん あめ",
"Matt", "Dave Abraham",
"Joaquin Hierrezuelo",
"Locrospiel", "Locrospiel",
"Frogmilk", "Sean voets",
"SPJ", "Jarrid Lee",
"Kor", "Kor",
"Joseph Hanson", "Joseph Hanson",
"John Rednoulf",
"Kyron Mahan", "Kyron Mahan",
"Bryan Rutkowski", "Boba Smith",
"TBitz33", "TBitz33",
"Anonym dkjglfleeoeldldldlkf", "Anonym dkjglfleeoeldldldlkf",
"Ezokewn", "Ezokewn",
"SendingRavens", "SendingRavens",
"Sauv",
"Steven",
"JackJohnnyJim", "JackJohnnyJim",
"TenaciousD", "TenaciousD",
"Dmitry Ryzhov", "Dmitry Ryzhov",
"Khánh Đặng", "Khánh Đặng",
"Edward Ten Eyck",
"Michael Docherty", "Michael Docherty",
"Jimmy Borup", "Jimmy Borup",
"Paul Hartsuyker", "Paul Hartsuyker",
@@ -530,16 +552,12 @@
"Pete Pain", "Pete Pain",
"Jacob Winter", "Jacob Winter",
"Ryan Presley Ng", "Ryan Presley Ng",
"jinksta187",
"RHopkirk", "RHopkirk",
"Andrew Wilkinson", "Andrew Wilkinson",
"Manu Thetug",
"Karlanx",
"Lyavph", "Lyavph",
"Maxim", "Maxim",
"David", "David",
"Meilo", "Meilo",
"operationancut",
"shinonomeiro", "shinonomeiro",
"Snille", "Snille",
"MaartenAlbers", "MaartenAlbers",
@@ -558,6 +576,20 @@
"Scott", "Scott",
"Muratoraccio", "Muratoraccio",
"D", "D",
"Akkas+Haque",
"Kachac",
"SAVEagleBasement",
"Kevin+Isom",
"Rune+Osnes",
"you+halo9",
"cloudghost",
"Yongkwan+Lee",
"PoorStudent",
"lucites",
"Alex+Zaw",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"nickname", "nickname",
"Sildoren", "Sildoren",
"Darv", "Darv",
@@ -575,61 +607,51 @@
"Lev+Lanevskiy", "Lev+Lanevskiy",
"low9", "low9",
"Winged", "Winged",
"YassineKhaled",
"Y",
"MatteKey",
"Flob",
"ShiroSenpai",
"Inkognito",
"G",
"Tan+Huynh",
"D",
"Dark_Pest",
"Jacky+Ho", "Jacky+Ho",
"generic404", "generic404",
"abattoirblues", "abattoirblues",
"zounik", "zounik",
"4IXplr0r3r", "4IXplr0r3r",
"hayden", "hayden",
"Obsidian.Studios",
"ahoystan", "ahoystan",
"Bob Barker", "Zomba Mann",
"edk", "edk",
"Tú Nguyễn Lý Hoàng", "Tú Nguyễn Lý Hoàng",
"shira1011", "shira1011",
"Neko Desco",
"Ben D", "Ben D",
"G", "G",
"Ronan Delevacq", "Vinarus",
"ja s", "ja s",
"Leslie Andrew Ridings", "Leslie Andrew Ridings",
"Doug Mason", "Doug Mason",
"Jeremy Townsend", "scoreswazey",
"Dave Abraham",
"Joaquin Hierrezuelo",
"Sean voets",
"Owen Gwosdz", "Owen Gwosdz",
"Jarrid Lee",
"Poophead27 Blyat", "Poophead27 Blyat",
"John Rednoulf", "Mythspire",
"Spire",
"AZ Party Oasis", "AZ Party Oasis",
"Boba Smith",
"Devil Lude", "Devil Lude",
"David Murcko", "David Murcko",
"TheFusion",
"MR.Bear", "MR.Bear",
"Jack Dole", "Jack Dole",
"matt", "matt",
"somethingtosay8", "somethingtosay8",
"3zS4QNQ4",
"Terminuz",
"ivistorm", "ivistorm",
"max blo", "max blo",
"Sauv", "Ivan Imes",
"Steven",
"CptNeo", "CptNeo",
"Jack Lawfield",
"Borte",
"Maso", "Maso",
"Ted Cart", "Ted Cart",
"Sage Himeros", "Sage Himeros",
"Eric Ketchum", "Eric Ketchum",
"Kevin Wallace", "Kevin Wallace",
"David Spearing", "Zeeble",
"ChicRic", "ChicRic",
"Tigon", "Tigon",
"BastardSama", "BastardSama",
@@ -637,11 +659,13 @@
"SkibidiRizzler", "SkibidiRizzler",
"Tania Nayelli Fernandez", "Tania Nayelli Fernandez",
"Draconach", "Draconach",
"Kalle Björk",
"Yavizu3d", "Yavizu3d",
"Yves Poezevara", "Yves Poezevara",
"Teriak47", "Teriak47",
"Just me", "Just me",
"Raf Stahelin", "Raf Stahelin",
"Nacho Ferrando",
"Вячеслав Маринин", "Вячеслав Маринин",
"Marcos Tortosa Carmona", "Marcos Tortosa Carmona",
"Dkommander22", "Dkommander22",
@@ -688,6 +712,15 @@
"SelfishMedic", "SelfishMedic",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"Somebody",
"Jasper",
"megameganck",
"thomasand01",
"Shiba+Sama",
"miduzza",
"KB",
"shw",
"Celestial+Kitten",
"bakeliteboy", "bakeliteboy",
"TequiTequi", "TequiTequi",
"Homero+Banda", "Homero+Banda",
@@ -706,26 +739,12 @@
"matter", "matter",
"SRCRCOSS", "SRCRCOSS",
"imer", "imer",
"Akkas+Haque",
"Kachac",
"tyrant2811",
"Kevin",
"Rune+Osnes",
"jcx29", "jcx29",
"cloudghost",
"Yongkwan+Lee",
"PoorStudent",
"lucites",
"Alex+Zaw",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"Drizzly", "Drizzly",
"Nebuleux", "Nebuleux",
"Join+Chun", "Join+Chun",
"GDS+DEV", "GDS+DEV",
"4rt+r3d", "4rt+r3d",
"you+halo9",
"Somebody", "Somebody",
"Somebody", "Somebody",
"Crescent~San", "Crescent~San",
@@ -738,34 +757,37 @@
"Bula", "Bula",
"KUJYAKU", "KUJYAKU",
"Coeur+de+cochon", "Coeur+de+cochon",
"Obsidian.Studios",
"han b", "han b",
"Zomba Mann",
"Aquaneo", "Aquaneo",
"Nico", "Nico",
"Maximilian Krischan", "Maximilian Krischan",
"Banana Joe", "Banana Joe",
"proto merp",
"_ G3n", "_ G3n",
"Brandon Thomas",
"Donovan Jenkins", "Donovan Jenkins",
"Hans Meier", "Hans Meier",
"sicarius", "sicarius",
"Michael Eid", "Michael Eid",
"Wolf and Fox Legends", "Wolf and Fox Legends",
"beersandbacon", "beersandbacon",
"Neko Desco",
"Bob barker", "Bob barker",
"Ninja Tom", "Ninja Tom",
"karim ben brik", "karim ben brik",
"Vinarus", "Elemnt",
"Josh Snyder", "Josh Snyder",
"Michael Zhu", "Michael Zhu",
"Nemisu", "Nemisu",
"Seraphy", "Seraphy",
"雨の心 落", "雨の心 落",
"AllTimeNoobie", "AllTimeNoobie",
"swra",
"JollRodrigo",
"jumpd", "jumpd",
"John C", "John C",
"Rim", "Rim",
"Oliverfish",
"yfx507",
"Room Light", "Room Light",
"Jairus Knudsen", "Jairus Knudsen",
"Xan Dionysus", "Xan Dionysus",
@@ -776,30 +798,29 @@
"Forbidden Atelier", "Forbidden Atelier",
"Thomas Sankowski", "Thomas Sankowski",
"DrB", "DrB",
"Nimhloth",
"Adictedtohumping", "Adictedtohumping",
"Snorklebort", "Snorklebort",
"vinter", "vinter",
"Towelie", "Towelie",
"TheFusion",
"Jean-françois SEMA", "Jean-françois SEMA",
"3zS4QNQ4",
"Terminuz",
"Kurt", "Kurt",
"Andrew Ly",
"Matt M.", "Matt M.",
"Ivan Imes",
"J M", "J M",
"Slacks",
"Bouya shaka", "Bouya shaka",
"john Greene",
"Faburizu", "Faburizu",
"Jack Lawfield",
"jimyjomson", "jimyjomson",
"Borte",
"JaeHyun Jang", "JaeHyun Jang",
"Homero Banda", "Homero Banda",
"Chase Kwon", "Chase Kwon",
"Bob Ling",
"yyuvuvu", "yyuvuvu",
"Inyoshu", "Inyoshu",
"Chad Barnes", "Chad Barnes",
"Person Y", "Adam Gardner",
"Nomki", "Nomki",
"inusanorthcape", "inusanorthcape",
"James Ming", "James Ming",
@@ -821,5 +842,5 @@
"Somebody", "Somebody",
"CK" "CK"
], ],
"totalCount": 818 "totalCount": 839
} }
+134 -17
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "Aus Favoriten entfernen", "removeFromFavorites": "Aus Favoriten entfernen",
"viewOnCivitai": "Auf Civitai anzeigen", "viewOnCivitai": "Auf Civitai anzeigen",
"notAvailableFromCivitai": "Nicht auf Civitai verfügbar", "notAvailableFromCivitai": "Nicht auf Civitai verfügbar",
"viewOnHuggingFace": "Auf Hugging Face ansehen",
"sendToWorkflow": "An ComfyUI senden (Klick: Anhängen, Shift+Klick: Ersetzen)", "sendToWorkflow": "An ComfyUI senden (Klick: Anhängen, Shift+Klick: Ersetzen)",
"copyLoRASyntax": "LoRA-Syntax kopieren", "copyLoRASyntax": "LoRA-Syntax kopieren",
"checkpointNameCopied": "Checkpoint-Name kopiert", "checkpointNameCopied": "Checkpoint-Name kopiert",
@@ -145,6 +146,10 @@
}, },
"usage": { "usage": {
"timesUsed": "Verwendungsanzahl" "timesUsed": "Verwendungsanzahl"
},
"footer": {
"versionCount": "{count} Versionen",
"viewAllVersions": "Alle lokalen Versionen anzeigen"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +188,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Ausgeschlossene Modelle verwalten" "label": "Ausgeschlossene Modelle verwalten"
},
"groupByModel": {
"label": "Nach Modell gruppieren"
} }
}, },
"header": { "header": {
@@ -195,13 +203,7 @@
"statistics": "Statistiken" "statistics": "Statistiken"
}, },
"search": { "search": {
"placeholder": "Suchen...", "placeholder": "Suchen",
"placeholders": {
"loras": "LoRAs suchen...",
"recipes": "Rezepte suchen...",
"checkpoints": "Checkpoints suchen...",
"embeddings": "Embeddings suchen..."
},
"options": "Suchoptionen", "options": "Suchoptionen",
"searchIn": "Suchen in:", "searchIn": "Suchen in:",
"notAvailable": "Suche auf Statistikseite nicht verfügbar", "notAvailable": "Suche auf Statistikseite nicht verfügbar",
@@ -251,7 +253,18 @@
"toggle": "Theme wechseln", "toggle": "Theme wechseln",
"switchToLight": "Zu hellem Theme wechseln", "switchToLight": "Zu hellem Theme wechseln",
"switchToDark": "Zu dunklem 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": { "actions": {
"checkUpdates": "Updates prüfen", "checkUpdates": "Updates prüfen",
@@ -263,6 +276,9 @@
"civitaiApiKey": "Civitai API Key", "civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein", "civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet", "civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
"civitaiApiKeyConfigured": "Konfiguriert",
"civitaiApiKeyNotConfigured": "Nicht konfiguriert",
"civitaiApiKeySet": "Einrichten",
"civitaiHost": { "civitaiHost": {
"label": "Civitai-Host", "label": "Civitai-Host",
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.", "help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
@@ -303,6 +319,7 @@
"downloads": "Downloads", "downloads": "Downloads",
"videoSettings": "Video-Einstellungen", "videoSettings": "Video-Einstellungen",
"layoutSettings": "Layout-Einstellungen", "layoutSettings": "Layout-Einstellungen",
"licenseIcons": "Lizenzsymbole",
"misc": "Verschiedenes", "misc": "Verschiedenes",
"backup": "Backups", "backup": "Backups",
"folderSettings": "Standard-Roots", "folderSettings": "Standard-Roots",
@@ -310,7 +327,7 @@
"extraFolderPaths": "Zusätzliche Ordnerpfade", "extraFolderPaths": "Zusätzliche Ordnerpfade",
"downloadPathTemplates": "Download-Pfad-Vorlagen", "downloadPathTemplates": "Download-Pfad-Vorlagen",
"priorityTags": "Prioritäts-Tags", "priorityTags": "Prioritäts-Tags",
"updateFlags": "Update-Markierungen", "versionScope": "Update-Markierungen",
"exampleImages": "Beispielbilder", "exampleImages": "Beispielbilder",
"autoOrganize": "Auto-Organisierung", "autoOrganize": "Auto-Organisierung",
"metadata": "Metadaten", "metadata": "Metadaten",
@@ -415,6 +432,8 @@
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe." "help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "Nach Modell gruppieren",
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
"displayDensity": "Anzeige-Dichte", "displayDensity": "Anzeige-Dichte",
"displayDensityOptions": { "displayDensityOptions": {
"default": "Standard", "default": "Standard",
@@ -571,7 +590,7 @@
"download": "Herunterladen", "download": "Herunterladen",
"restartRequired": "Neustart erforderlich" "restartRequired": "Neustart erforderlich"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "Strategie für Update-Markierungen", "label": "Strategie für Update-Markierungen",
"help": "Entscheide, ob Update-Badges nur dann erscheinen, wenn eine neue Version dasselbe Basismodell wie deine lokalen Dateien verwendet, oder sobald es irgendein neueres Release für dieses Modell gibt.", "help": "Entscheide, ob Update-Badges nur dann erscheinen, wenn eine neue Version dasselbe Basismodell wie deine lokalen Dateien verwendet, oder sobald es irgendein neueres Release für dieses Modell gibt.",
"options": { "options": {
@@ -583,6 +602,10 @@
"label": "Früher Zugriff Updates ausblenden", "label": "Früher Zugriff Updates ausblenden",
"help": "Nur Early-Access-Updates" "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": { "misc": {
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen", "includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen", "includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen",
@@ -651,7 +674,11 @@
"sizeAsc": "Kleinste", "sizeAsc": "Kleinste",
"usage": "Anzahl Nutzung", "usage": "Anzahl Nutzung",
"usageDesc": "Meiste", "usageDesc": "Meiste",
"usageAsc": "Wenigste" "usageAsc": "Wenigste",
"versionsCount": "Lokale Versionen",
"versionsCountDesc": "Meiste Versionen zuerst",
"versionsCountAsc": "Wenigste Versionen zuerst",
"versionIdDesc": "Neueste Version zuerst"
}, },
"refresh": { "refresh": {
"title": "Modelliste aktualisieren", "title": "Modelliste aktualisieren",
@@ -997,6 +1024,18 @@
"storage": "Speicher", "storage": "Speicher",
"insights": "Erkenntnisse" "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": { "usage": {
"mostUsedLoras": "Meistgenutzte LoRAs", "mostUsedLoras": "Meistgenutzte LoRAs",
"mostUsedCheckpoints": "Meistgenutzte Checkpoints", "mostUsedCheckpoints": "Meistgenutzte Checkpoints",
@@ -1014,13 +1053,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Intelligente Erkenntnisse", "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": { "charts": {
"collectionOverview": "Sammlungsübersicht", "collectionOverview": "Sammlungsübersicht",
"baseModelDistribution": "Basis-Modell-Verteilung", "baseModelDistribution": "Basis-Modell-Verteilung",
"usageTrends": "Nutzungstrends (Letzte 30 Tage)", "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": { "modals": {
@@ -1032,7 +1135,10 @@
"titleWithType": "{type} von URL herunterladen", "titleWithType": "{type} von URL herunterladen",
"civitaiUrl": "Civitai URL:", "civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "Geben Sie eine CivitAI- oder CivArchive-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.", "urlHint": "Geben Sie eine CivitAI-, CivArchive- oder Hugging Face-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
"selectHfFiles": "Datei(en) zum Herunterladen aus diesem Repository auswählen:",
"selectAll": "Alle auswählen",
"fetchingRepoFiles": "Repository-Dateien werden abgerufen...",
"locationPreview": "Download-Speicherort Vorschau", "locationPreview": "Download-Speicherort Vorschau",
"useDefaultPath": "Standardpfad verwenden", "useDefaultPath": "Standardpfad verwenden",
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert", "useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
@@ -1061,7 +1167,9 @@
}, },
"errors": { "errors": {
"invalidUrl": "Ungültiges Civitai URL-Format", "invalidUrl": "Ungültiges Civitai URL-Format",
"noVersions": "Keine Versionen für dieses Modell verfügbar" "noVersions": "Keine Versionen für dieses Modell verfügbar",
"mixedSources": "CivitAI- und Hugging Face-URLs können nicht in derselben Charge gemischt werden.",
"noModelFiles": "In diesem Repository wurden keine Modelldateien gefunden."
}, },
"status": { "status": {
"preparing": "Download wird vorbereitet...", "preparing": "Download wird vorbereitet...",
@@ -1212,6 +1320,8 @@
"editVersionName": "Versionsname bearbeiten", "editVersionName": "Versionsname bearbeiten",
"viewOnCivitai": "Auf Civitai anzeigen", "viewOnCivitai": "Auf Civitai anzeigen",
"viewOnCivitaiText": "Auf Civitai anzeigen", "viewOnCivitaiText": "Auf Civitai anzeigen",
"viewOnHuggingFace": "Auf Hugging Face ansehen",
"viewOnHuggingFaceText": "Auf Hugging Face ansehen",
"viewCreatorProfile": "Ersteller-Profil anzeigen", "viewCreatorProfile": "Ersteller-Profil anzeigen",
"openFileLocation": "Dateispeicherort öffnen", "openFileLocation": "Dateispeicherort öffnen",
"sendToWorkflow": "An ComfyUI senden", "sendToWorkflow": "An ComfyUI senden",
@@ -1237,7 +1347,10 @@
"additionalNotes": "Zusätzliche Notizen", "additionalNotes": "Zusätzliche Notizen",
"notesHint": "Enter zum Speichern, Shift+Enter für neue Zeile", "notesHint": "Enter zum Speichern, Shift+Enter für neue Zeile",
"addNotesPlaceholder": "Fügen Sie hier Ihre Notizen hinzu...", "addNotesPlaceholder": "Fügen Sie hier Ihre Notizen hinzu...",
"aboutThisVersion": "Über diese Version" "aboutThisVersion": "Über diese Version",
"baseModelSearchPlaceholder": "Basismodell suchen…",
"baseModelSuggested": "Vorschlag",
"baseModelNoMatch": "Keine passenden Basismodelle"
}, },
"notes": { "notes": {
"saved": "Notizen erfolgreich gespeichert", "saved": "Notizen erfolgreich gespeichert",
@@ -1513,12 +1626,15 @@
"modelUpdated": "Modell im Workflow aktualisiert", "modelUpdated": "Modell im Workflow aktualisiert",
"modelFailed": "Fehler beim Aktualisieren des Modellknotens", "modelFailed": "Fehler beim Aktualisieren des Modellknotens",
"embeddingAdded": "Embedding zum Workflow hinzugefügt", "embeddingAdded": "Embedding zum Workflow hinzugefügt",
"embeddingFailed": "Fehler beim Hinzufügen des Embeddings" "embeddingFailed": "Fehler beim Hinzufügen des Embeddings",
"promptSent": "Prompt an Workflow gesendet",
"promptFailed": "Fehler beim Senden des Prompts"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Rezept", "recipe": "Rezept",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding", "embedding": "Embedding",
"prompt": "Prompt",
"replace": "Ersetzen", "replace": "Ersetzen",
"append": "Anhängen", "append": "Anhängen",
"selectTargetNode": "Zielknoten auswählen", "selectTargetNode": "Zielknoten auswählen",
@@ -1705,6 +1821,7 @@
"enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein", "enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein",
"reconnectedSuccessfully": "LoRA erfolgreich neu verbunden", "reconnectedSuccessfully": "LoRA erfolgreich neu verbunden",
"reconnectFailed": "Fehler beim Neuverbinden des LoRA: {message}", "reconnectFailed": "Fehler beim Neuverbinden des LoRA: {message}",
"noPromptToSend": "Kein zu sendender Prompt",
"cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID", "cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID",
"sendFailed": "Fehler beim Senden des Rezepts an Workflow", "sendFailed": "Fehler beim Senden des Rezepts an Workflow",
"sendError": "Fehler beim Senden des Rezepts an Workflow", "sendError": "Fehler beim Senden des Rezepts an Workflow",
+139 -22
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "Remove from favorites", "removeFromFavorites": "Remove from favorites",
"viewOnCivitai": "View on Civitai", "viewOnCivitai": "View on Civitai",
"notAvailableFromCivitai": "Not available from Civitai", "notAvailableFromCivitai": "Not available from Civitai",
"viewOnHuggingFace": "View on Hugging Face",
"sendToWorkflow": "Send to ComfyUI (Click: Append, Shift+Click: Replace)", "sendToWorkflow": "Send to ComfyUI (Click: Append, Shift+Click: Replace)",
"copyLoRASyntax": "Copy LoRA Syntax", "copyLoRASyntax": "Copy LoRA Syntax",
"checkpointNameCopied": "Checkpoint name copied", "checkpointNameCopied": "Checkpoint name copied",
@@ -145,6 +146,10 @@
}, },
"usage": { "usage": {
"timesUsed": "Times used" "timesUsed": "Times used"
},
"footer": {
"versionCount": "{count} versions",
"viewAllVersions": "View all local versions"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +188,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Manage Excluded Models" "label": "Manage Excluded Models"
},
"groupByModel": {
"label": "Group by Model"
} }
}, },
"header": { "header": {
@@ -195,13 +203,7 @@
"statistics": "Stats" "statistics": "Stats"
}, },
"search": { "search": {
"placeholder": "Search...", "placeholder": "Search",
"placeholders": {
"loras": "Search LoRAs...",
"recipes": "Search recipes...",
"checkpoints": "Search checkpoints...",
"embeddings": "Search embeddings..."
},
"options": "Search Options", "options": "Search Options",
"searchIn": "Search In:", "searchIn": "Search In:",
"notAvailable": "Search not available on statistics page", "notAvailable": "Search not available on statistics page",
@@ -251,7 +253,18 @@
"toggle": "Toggle theme", "toggle": "Toggle theme",
"switchToLight": "Switch to light theme", "switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark 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": { "actions": {
"checkUpdates": "Check Updates", "checkUpdates": "Check Updates",
@@ -263,6 +276,9 @@
"civitaiApiKey": "Civitai API Key", "civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Enter your Civitai API key", "civitaiApiKeyPlaceholder": "Enter your Civitai API key",
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai", "civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
"civitaiApiKeyConfigured": "Configured",
"civitaiApiKeyNotConfigured": "Not configured",
"civitaiApiKeySet": "Set up",
"civitaiHost": { "civitaiHost": {
"label": "Civitai host", "label": "Civitai host",
"help": "Choose which Civitai site opens when using View on Civitai links.", "help": "Choose which Civitai site opens when using View on Civitai links.",
@@ -303,6 +319,7 @@
"downloads": "Downloads", "downloads": "Downloads",
"videoSettings": "Video Settings", "videoSettings": "Video Settings",
"layoutSettings": "Layout Settings", "layoutSettings": "Layout Settings",
"licenseIcons": "License Icons",
"misc": "Miscellaneous", "misc": "Miscellaneous",
"backup": "Backups", "backup": "Backups",
"folderSettings": "Default Roots", "folderSettings": "Default Roots",
@@ -310,7 +327,7 @@
"extraFolderPaths": "Extra Folder Paths", "extraFolderPaths": "Extra Folder Paths",
"downloadPathTemplates": "Download Path Templates", "downloadPathTemplates": "Download Path Templates",
"priorityTags": "Priority Tags", "priorityTags": "Priority Tags",
"updateFlags": "Update Flags", "versionScope": "Version Scope",
"exampleImages": "Example Images", "exampleImages": "Example Images",
"autoOrganize": "Auto-organize", "autoOrganize": "Auto-organize",
"metadata": "Metadata", "metadata": "Metadata",
@@ -415,6 +432,8 @@
"help": "When enabled, versions downloaded before will be skipped." "help": "When enabled, versions downloaded before will be skipped."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "Group by Model",
"groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.",
"displayDensity": "Display Density", "displayDensity": "Display Density",
"displayDensityOptions": { "displayDensityOptions": {
"default": "Default", "default": "Default",
@@ -571,18 +590,22 @@
"download": "Download", "download": "Download",
"restartRequired": "Requires restart" "restartRequired": "Requires restart"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "Update Flag Strategy", "label": "Version Grouping",
"help": "Decide whether update badges should only appear when a new release shares the same base model as your local files or whenever any newer version exists for that model.", "help": "Decide how versions are grouped for display: by base model or all together. Also controls update badge logic and the VLM version list filtering.",
"options": { "options": {
"sameBase": "Match updates by base model", "sameBase": "Group by base model (same_base)",
"any": "Flag any available update" "any": "Show all versions (any)"
} }
}, },
"hideEarlyAccessUpdates": { "hideEarlyAccessUpdates": {
"label": "Hide Early Access Updates", "label": "Hide Early Access Updates",
"help": "When enabled, models with only early access updates will not show 'Update available' badge" "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": { "misc": {
"includeTriggerWords": "Include Trigger Words in LoRA Syntax", "includeTriggerWords": "Include Trigger Words in LoRA Syntax",
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard", "includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard",
@@ -651,7 +674,11 @@
"sizeAsc": "Smallest", "sizeAsc": "Smallest",
"usage": "Use Count", "usage": "Use Count",
"usageDesc": "Most", "usageDesc": "Most",
"usageAsc": "Least" "usageAsc": "Least",
"versionsCount": "Local Versions",
"versionsCountDesc": "Most versions first",
"versionsCountAsc": "Fewest versions first",
"versionIdDesc": "Newest version first"
}, },
"refresh": { "refresh": {
"title": "Refresh model list", "title": "Refresh model list",
@@ -997,6 +1024,18 @@
"storage": "Storage", "storage": "Storage",
"insights": "Insights" "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": { "usage": {
"mostUsedLoras": "Most Used LoRAs", "mostUsedLoras": "Most Used LoRAs",
"mostUsedCheckpoints": "Most Used Checkpoints", "mostUsedCheckpoints": "Most Used Checkpoints",
@@ -1014,13 +1053,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Smart 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": { "charts": {
"collectionOverview": "Collection Overview", "collectionOverview": "Collection Overview",
"baseModelDistribution": "Base Model Distribution", "baseModelDistribution": "Base Model Distribution",
"usageTrends": "Usage Trends (Last 30 Days)", "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": { "modals": {
@@ -1032,7 +1135,10 @@
"titleWithType": "Download {type} from URL", "titleWithType": "Download {type} from URL",
"civitaiUrl": "Civitai URL(s):", "civitaiUrl": "Civitai URL(s):",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "Enter one CivitAI or CivArchive URL per line. Supports multiple URLs for batch download.", "urlHint": "Enter one CivitAI, CivArchive, or Hugging Face URL per line. Supports multiple URLs for batch download.",
"selectHfFiles": "Select file(s) to download from this repository:",
"selectAll": "Select All",
"fetchingRepoFiles": "Fetching repository files...",
"locationPreview": "Download Location Preview", "locationPreview": "Download Location Preview",
"useDefaultPath": "Use Default Path", "useDefaultPath": "Use Default Path",
"useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates", "useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates",
@@ -1061,7 +1167,9 @@
}, },
"errors": { "errors": {
"invalidUrl": "Invalid Civitai URL format", "invalidUrl": "Invalid Civitai URL format",
"noVersions": "No versions available for this model" "noVersions": "No versions available for this model",
"mixedSources": "Cannot mix CivitAI and Hugging Face URLs in the same batch.",
"noModelFiles": "No model files found in this repository."
}, },
"status": { "status": {
"preparing": "Preparing download...", "preparing": "Preparing download...",
@@ -1212,6 +1320,8 @@
"editVersionName": "Edit version name", "editVersionName": "Edit version name",
"viewOnCivitai": "View on Civitai", "viewOnCivitai": "View on Civitai",
"viewOnCivitaiText": "View on Civitai", "viewOnCivitaiText": "View on Civitai",
"viewOnHuggingFace": "View on Hugging Face",
"viewOnHuggingFaceText": "View on Hugging Face",
"viewCreatorProfile": "View Creator Profile", "viewCreatorProfile": "View Creator Profile",
"openFileLocation": "Open File Location", "openFileLocation": "Open File Location",
"sendToWorkflow": "Send to ComfyUI", "sendToWorkflow": "Send to ComfyUI",
@@ -1237,7 +1347,10 @@
"additionalNotes": "Additional Notes", "additionalNotes": "Additional Notes",
"notesHint": "Press Enter to save, Shift+Enter for new line", "notesHint": "Press Enter to save, Shift+Enter for new line",
"addNotesPlaceholder": "Add your notes here...", "addNotesPlaceholder": "Add your notes here...",
"aboutThisVersion": "About this version" "aboutThisVersion": "About this version",
"baseModelSearchPlaceholder": "Search base model…",
"baseModelSuggested": "Suggested",
"baseModelNoMatch": "No matching base models"
}, },
"notes": { "notes": {
"saved": "Notes saved successfully", "saved": "Notes saved successfully",
@@ -1368,7 +1481,7 @@
"resumeModelUpdates": "Resume updates for this model", "resumeModelUpdates": "Resume updates for this model",
"ignoreModelUpdates": "Ignore updates for this model", "ignoreModelUpdates": "Ignore updates for this model",
"viewLocalVersions": "View all local versions", "viewLocalVersions": "View all local versions",
"viewLocalTooltip": "Coming soon" "viewLocalTooltip": "Show all local versions of this model on the main page"
}, },
"filters": { "filters": {
"label": "Base filter", "label": "Base filter",
@@ -1513,12 +1626,15 @@
"modelUpdated": "Model updated in workflow", "modelUpdated": "Model updated in workflow",
"modelFailed": "Failed to update model node", "modelFailed": "Failed to update model node",
"embeddingAdded": "Embedding added to workflow", "embeddingAdded": "Embedding added to workflow",
"embeddingFailed": "Failed to add embedding" "embeddingFailed": "Failed to add embedding",
"promptSent": "Prompt sent to workflow",
"promptFailed": "Failed to send prompt"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Recipe", "recipe": "Recipe",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding", "embedding": "Embedding",
"prompt": "Prompt",
"replace": "Replace", "replace": "Replace",
"append": "Append", "append": "Append",
"selectTargetNode": "Select target node", "selectTargetNode": "Select target node",
@@ -1705,6 +1821,7 @@
"enterLoraName": "Please enter a LoRA name or syntax", "enterLoraName": "Please enter a LoRA name or syntax",
"reconnectedSuccessfully": "LoRA reconnected successfully", "reconnectedSuccessfully": "LoRA reconnected successfully",
"reconnectFailed": "Error reconnecting LoRA: {message}", "reconnectFailed": "Error reconnecting LoRA: {message}",
"noPromptToSend": "No prompt to send",
"cannotSend": "Cannot send recipe: Missing recipe ID", "cannotSend": "Cannot send recipe: Missing recipe ID",
"sendFailed": "Failed to send recipe to workflow", "sendFailed": "Failed to send recipe to workflow",
"sendError": "Error sending recipe to workflow", "sendError": "Error sending recipe to workflow",
+134 -17
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "Eliminar de favoritos", "removeFromFavorites": "Eliminar de favoritos",
"viewOnCivitai": "Ver en Civitai", "viewOnCivitai": "Ver en Civitai",
"notAvailableFromCivitai": "No disponible en Civitai", "notAvailableFromCivitai": "No disponible en Civitai",
"viewOnHuggingFace": "Ver en Hugging Face",
"sendToWorkflow": "Enviar a ComfyUI (Clic: Añadir, Shift+Clic: Reemplazar)", "sendToWorkflow": "Enviar a ComfyUI (Clic: Añadir, Shift+Clic: Reemplazar)",
"copyLoRASyntax": "Copiar sintaxis de LoRA", "copyLoRASyntax": "Copiar sintaxis de LoRA",
"checkpointNameCopied": "Nombre del checkpoint copiado", "checkpointNameCopied": "Nombre del checkpoint copiado",
@@ -145,6 +146,10 @@
}, },
"usage": { "usage": {
"timesUsed": "Veces usado" "timesUsed": "Veces usado"
},
"footer": {
"versionCount": "{count} versiones",
"viewAllVersions": "Ver todas las versiones locales"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +188,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Gestionar modelos excluidos" "label": "Gestionar modelos excluidos"
},
"groupByModel": {
"label": "Agrupar por modelo"
} }
}, },
"header": { "header": {
@@ -195,13 +203,7 @@
"statistics": "Estadísticas" "statistics": "Estadísticas"
}, },
"search": { "search": {
"placeholder": "Buscar...", "placeholder": "Buscar",
"placeholders": {
"loras": "Buscar LoRAs...",
"recipes": "Buscar recetas...",
"checkpoints": "Buscar checkpoints...",
"embeddings": "Buscar embeddings..."
},
"options": "Opciones de búsqueda", "options": "Opciones de búsqueda",
"searchIn": "Buscar en:", "searchIn": "Buscar en:",
"notAvailable": "Búsqueda no disponible en la página de estadísticas", "notAvailable": "Búsqueda no disponible en la página de estadísticas",
@@ -251,7 +253,18 @@
"toggle": "Cambiar tema", "toggle": "Cambiar tema",
"switchToLight": "Cambiar a tema claro", "switchToLight": "Cambiar a tema claro",
"switchToDark": "Cambiar a tema oscuro", "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": { "actions": {
"checkUpdates": "Comprobar actualizaciones", "checkUpdates": "Comprobar actualizaciones",
@@ -263,6 +276,9 @@
"civitaiApiKey": "Clave API de Civitai", "civitaiApiKey": "Clave API de Civitai",
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai", "civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai", "civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
"civitaiApiKeyConfigured": "Configurado",
"civitaiApiKeyNotConfigured": "No configurado",
"civitaiApiKeySet": "Configurar",
"civitaiHost": { "civitaiHost": {
"label": "Host de Civitai", "label": "Host de Civitai",
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".", "help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
@@ -303,6 +319,7 @@
"downloads": "Descargas", "downloads": "Descargas",
"videoSettings": "Configuración de video", "videoSettings": "Configuración de video",
"layoutSettings": "Configuración de diseño", "layoutSettings": "Configuración de diseño",
"licenseIcons": "Iconos de licencia",
"misc": "Varios", "misc": "Varios",
"backup": "Copias de seguridad", "backup": "Copias de seguridad",
"folderSettings": "Raíces predeterminadas", "folderSettings": "Raíces predeterminadas",
@@ -310,7 +327,7 @@
"extraFolderPaths": "Rutas de carpetas adicionales", "extraFolderPaths": "Rutas de carpetas adicionales",
"downloadPathTemplates": "Plantillas de rutas de descarga", "downloadPathTemplates": "Plantillas de rutas de descarga",
"priorityTags": "Etiquetas prioritarias", "priorityTags": "Etiquetas prioritarias",
"updateFlags": "Indicadores de actualización", "versionScope": "Indicadores de actualización",
"exampleImages": "Imágenes de ejemplo", "exampleImages": "Imágenes de ejemplo",
"autoOrganize": "Organización automática", "autoOrganize": "Organización automática",
"metadata": "Metadatos", "metadata": "Metadatos",
@@ -415,6 +432,8 @@
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga." "help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "Agrupar por modelo",
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de Civitai como una tarjeta única. Las versiones anteriores están ocultas.",
"displayDensity": "Densidad de visualización", "displayDensity": "Densidad de visualización",
"displayDensityOptions": { "displayDensityOptions": {
"default": "Predeterminado", "default": "Predeterminado",
@@ -571,7 +590,7 @@
"download": "Descargar", "download": "Descargar",
"restartRequired": "Requiere reinicio" "restartRequired": "Requiere reinicio"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "Estrategia de indicadores de actualización", "label": "Estrategia de indicadores de actualización",
"help": "Decide si las insignias de actualización deben mostrarse solo cuando una nueva versión comparte el mismo modelo base que tus archivos locales o siempre que exista cualquier versión más reciente de ese modelo.", "help": "Decide si las insignias de actualización deben mostrarse solo cuando una nueva versión comparte el mismo modelo base que tus archivos locales o siempre que exista cualquier versión más reciente de ese modelo.",
"options": { "options": {
@@ -583,6 +602,10 @@
"label": "Ocultar actualizaciones de acceso temprano", "label": "Ocultar actualizaciones de acceso temprano",
"help": "Solo 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": { "misc": {
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA", "includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles", "includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles",
@@ -651,7 +674,11 @@
"sizeAsc": "Menor", "sizeAsc": "Menor",
"usage": "Número de usos", "usage": "Número de usos",
"usageDesc": "Más", "usageDesc": "Más",
"usageAsc": "Menos" "usageAsc": "Menos",
"versionsCount": "Versiones locales",
"versionsCountDesc": "Más versiones primero",
"versionsCountAsc": "Menos versiones primero",
"versionIdDesc": "Versión más nueva primero"
}, },
"refresh": { "refresh": {
"title": "Actualizar lista de modelos", "title": "Actualizar lista de modelos",
@@ -997,6 +1024,18 @@
"storage": "Almacenamiento", "storage": "Almacenamiento",
"insights": "Perspectivas" "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": { "usage": {
"mostUsedLoras": "LoRAs más utilizados", "mostUsedLoras": "LoRAs más utilizados",
"mostUsedCheckpoints": "Checkpoints más utilizados", "mostUsedCheckpoints": "Checkpoints más utilizados",
@@ -1014,13 +1053,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Perspectivas inteligentes", "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": { "charts": {
"collectionOverview": "Resumen de colección", "collectionOverview": "Resumen de colección",
"baseModelDistribution": "Distribución de modelo base", "baseModelDistribution": "Distribución de modelo base",
"usageTrends": "Tendencias de uso (Últimos 30 días)", "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": { "modals": {
@@ -1032,7 +1135,10 @@
"titleWithType": "Descargar {type} desde URL", "titleWithType": "Descargar {type} desde URL",
"civitaiUrl": "URL de Civitai:", "civitaiUrl": "URL de Civitai:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "Ingrese una URL de CivitAI o CivArchive por línea. Admite múltiples URLs para descarga por lotes.", "urlHint": "Ingrese una URL de CivitAI, CivArchive o Hugging Face por línea. Admite múltiples URLs para descarga por lotes.",
"selectHfFiles": "Seleccione el/los archivo(s) para descargar de este repositorio:",
"selectAll": "Seleccionar todo",
"fetchingRepoFiles": "Obteniendo archivos del repositorio...",
"locationPreview": "Vista previa de ubicación de descarga", "locationPreview": "Vista previa de ubicación de descarga",
"useDefaultPath": "Usar ruta predeterminada", "useDefaultPath": "Usar ruta predeterminada",
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas", "useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
@@ -1061,7 +1167,9 @@
}, },
"errors": { "errors": {
"invalidUrl": "Formato de URL de Civitai inválido", "invalidUrl": "Formato de URL de Civitai inválido",
"noVersions": "No hay versiones disponibles para este modelo" "noVersions": "No hay versiones disponibles para este modelo",
"mixedSources": "No se pueden mezclar URL de CivitAI y Hugging Face en el mismo lote.",
"noModelFiles": "No se encontraron archivos de modelo en este repositorio."
}, },
"status": { "status": {
"preparing": "Preparando descarga...", "preparing": "Preparando descarga...",
@@ -1212,6 +1320,8 @@
"editVersionName": "Editar nombre de versión", "editVersionName": "Editar nombre de versión",
"viewOnCivitai": "Ver en Civitai", "viewOnCivitai": "Ver en Civitai",
"viewOnCivitaiText": "Ver en Civitai", "viewOnCivitaiText": "Ver en Civitai",
"viewOnHuggingFace": "Ver en Hugging Face",
"viewOnHuggingFaceText": "Ver en Hugging Face",
"viewCreatorProfile": "Ver perfil del creador", "viewCreatorProfile": "Ver perfil del creador",
"openFileLocation": "Abrir ubicación del archivo", "openFileLocation": "Abrir ubicación del archivo",
"sendToWorkflow": "Enviar a ComfyUI", "sendToWorkflow": "Enviar a ComfyUI",
@@ -1237,7 +1347,10 @@
"additionalNotes": "Notas adicionales", "additionalNotes": "Notas adicionales",
"notesHint": "Presiona Enter para guardar, Shift+Enter para nueva línea", "notesHint": "Presiona Enter para guardar, Shift+Enter para nueva línea",
"addNotesPlaceholder": "Añade tus notas aquí...", "addNotesPlaceholder": "Añade tus notas aquí...",
"aboutThisVersion": "Acerca de esta versión" "aboutThisVersion": "Acerca de esta versión",
"baseModelSearchPlaceholder": "Buscar modelo base…",
"baseModelSuggested": "Sugerido",
"baseModelNoMatch": "No hay modelos base que coincidan"
}, },
"notes": { "notes": {
"saved": "Notas guardadas exitosamente", "saved": "Notas guardadas exitosamente",
@@ -1513,12 +1626,15 @@
"modelUpdated": "Modelo actualizado en el flujo de trabajo", "modelUpdated": "Modelo actualizado en el flujo de trabajo",
"modelFailed": "Error al actualizar nodo de modelo", "modelFailed": "Error al actualizar nodo de modelo",
"embeddingAdded": "Embedding añadido al flujo de trabajo", "embeddingAdded": "Embedding añadido al flujo de trabajo",
"embeddingFailed": "Error al añadir el embedding" "embeddingFailed": "Error al añadir el embedding",
"promptSent": "Prompt enviado al flujo de trabajo",
"promptFailed": "Error al enviar el prompt"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Receta", "recipe": "Receta",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding", "embedding": "Embedding",
"prompt": "Prompt",
"replace": "Reemplazar", "replace": "Reemplazar",
"append": "Añadir", "append": "Añadir",
"selectTargetNode": "Seleccionar nodo de destino", "selectTargetNode": "Seleccionar nodo de destino",
@@ -1705,6 +1821,7 @@
"enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis", "enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis",
"reconnectedSuccessfully": "LoRA reconectado exitosamente", "reconnectedSuccessfully": "LoRA reconectado exitosamente",
"reconnectFailed": "Error reconectando LoRA: {message}", "reconnectFailed": "Error reconectando LoRA: {message}",
"noPromptToSend": "No hay prompt para enviar",
"cannotSend": "No se puede enviar receta: Falta ID de receta", "cannotSend": "No se puede enviar receta: Falta ID de receta",
"sendFailed": "Error al enviar receta al flujo de trabajo", "sendFailed": "Error al enviar receta al flujo de trabajo",
"sendError": "Error enviando receta al flujo de trabajo", "sendError": "Error enviando receta al flujo de trabajo",
+134 -17
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "Retirer des favoris", "removeFromFavorites": "Retirer des favoris",
"viewOnCivitai": "Voir sur Civitai", "viewOnCivitai": "Voir sur Civitai",
"notAvailableFromCivitai": "Non disponible sur Civitai", "notAvailableFromCivitai": "Non disponible sur Civitai",
"viewOnHuggingFace": "Voir sur Hugging Face",
"sendToWorkflow": "Envoyer vers ComfyUI (Clic: Ajouter, Maj+Clic: Remplacer)", "sendToWorkflow": "Envoyer vers ComfyUI (Clic: Ajouter, Maj+Clic: Remplacer)",
"copyLoRASyntax": "Copier la syntaxe LoRA", "copyLoRASyntax": "Copier la syntaxe LoRA",
"checkpointNameCopied": "Nom du checkpoint copié", "checkpointNameCopied": "Nom du checkpoint copié",
@@ -145,6 +146,10 @@
}, },
"usage": { "usage": {
"timesUsed": "Nombre d'utilisations" "timesUsed": "Nombre d'utilisations"
},
"footer": {
"versionCount": "{count} versions",
"viewAllVersions": "Voir toutes les versions locales"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +188,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Gérer les modèles exclus" "label": "Gérer les modèles exclus"
},
"groupByModel": {
"label": "Grouper par modèle"
} }
}, },
"header": { "header": {
@@ -195,13 +203,7 @@
"statistics": "Statistiques" "statistics": "Statistiques"
}, },
"search": { "search": {
"placeholder": "Rechercher...", "placeholder": "Rechercher",
"placeholders": {
"loras": "Rechercher des LoRAs...",
"recipes": "Rechercher des recipes...",
"checkpoints": "Rechercher des checkpoints...",
"embeddings": "Rechercher des embeddings..."
},
"options": "Options de recherche", "options": "Options de recherche",
"searchIn": "Rechercher dans :", "searchIn": "Rechercher dans :",
"notAvailable": "Recherche non disponible sur la page de statistiques", "notAvailable": "Recherche non disponible sur la page de statistiques",
@@ -251,7 +253,18 @@
"toggle": "Basculer le thème", "toggle": "Basculer le thème",
"switchToLight": "Passer au thème clair", "switchToLight": "Passer au thème clair",
"switchToDark": "Passer au thème sombre", "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": { "actions": {
"checkUpdates": "Vérifier les mises à jour", "checkUpdates": "Vérifier les mises à jour",
@@ -263,6 +276,9 @@
"civitaiApiKey": "Clé API Civitai", "civitaiApiKey": "Clé API Civitai",
"civitaiApiKeyPlaceholder": "Entrez votre 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", "civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
"civitaiApiKeyConfigured": "Configuré",
"civitaiApiKeyNotConfigured": "Non configuré",
"civitaiApiKeySet": "Configurer",
"civitaiHost": { "civitaiHost": {
"label": "Hôte Civitai", "label": "Hôte Civitai",
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».", "help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
@@ -303,6 +319,7 @@
"downloads": "Téléchargements", "downloads": "Téléchargements",
"videoSettings": "Paramètres vidéo", "videoSettings": "Paramètres vidéo",
"layoutSettings": "Paramètres d'affichage", "layoutSettings": "Paramètres d'affichage",
"licenseIcons": "Icônes de licence",
"misc": "Divers", "misc": "Divers",
"backup": "Sauvegardes", "backup": "Sauvegardes",
"folderSettings": "Racines par défaut", "folderSettings": "Racines par défaut",
@@ -310,7 +327,7 @@
"extraFolderPaths": "Chemins de dossiers supplémentaires", "extraFolderPaths": "Chemins de dossiers supplémentaires",
"downloadPathTemplates": "Modèles de chemin de téléchargement", "downloadPathTemplates": "Modèles de chemin de téléchargement",
"priorityTags": "Étiquettes prioritaires", "priorityTags": "Étiquettes prioritaires",
"updateFlags": "Indicateurs de mise à jour", "versionScope": "Indicateurs de mise à jour",
"exampleImages": "Images d'exemple", "exampleImages": "Images d'exemple",
"autoOrganize": "Organisation automatique", "autoOrganize": "Organisation automatique",
"metadata": "Métadonnées", "metadata": "Métadonnées",
@@ -415,6 +432,8 @@
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement." "help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "Grouper par modèle",
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle Civitai s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
"displayDensity": "Densité d'affichage", "displayDensity": "Densité d'affichage",
"displayDensityOptions": { "displayDensityOptions": {
"default": "Par défaut", "default": "Par défaut",
@@ -571,7 +590,7 @@
"download": "Télécharger", "download": "Télécharger",
"restartRequired": "Redémarrage requis" "restartRequired": "Redémarrage requis"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "Stratégie des indicateurs de mise à jour", "label": "Stratégie des indicateurs de mise à jour",
"help": "Choisissez si les badges de mise à jour doivent apparaître uniquement lorsquune nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès quil existe une version plus récente pour ce modèle.", "help": "Choisissez si les badges de mise à jour doivent apparaître uniquement lorsquune nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès quil existe une version plus récente pour ce modèle.",
"options": { "options": {
@@ -583,6 +602,10 @@
"label": "Masquer les mises à jour en accès anticipé", "label": "Masquer les mises à jour en accès anticipé",
"help": "Seulement 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": { "misc": {
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA", "includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers", "includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers",
@@ -651,7 +674,11 @@
"sizeAsc": "Plus petit", "sizeAsc": "Plus petit",
"usage": "Nombre d'utilisations", "usage": "Nombre d'utilisations",
"usageDesc": "Plus", "usageDesc": "Plus",
"usageAsc": "Moins" "usageAsc": "Moins",
"versionsCount": "Versions locales",
"versionsCountDesc": "Plus de versions d'abord",
"versionsCountAsc": "Moins de versions d'abord",
"versionIdDesc": "Version la plus récente d'abord"
}, },
"refresh": { "refresh": {
"title": "Actualiser la liste des modèles", "title": "Actualiser la liste des modèles",
@@ -997,6 +1024,18 @@
"storage": "Stockage", "storage": "Stockage",
"insights": "Aperçus" "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": { "usage": {
"mostUsedLoras": "LoRAs les plus utilisés", "mostUsedLoras": "LoRAs les plus utilisés",
"mostUsedCheckpoints": "Checkpoints les plus utilisés", "mostUsedCheckpoints": "Checkpoints les plus utilisés",
@@ -1014,13 +1053,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Aperçus intelligents", "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": { "charts": {
"collectionOverview": "Aperçu de la collection", "collectionOverview": "Aperçu de la collection",
"baseModelDistribution": "Distribution des modèles de base", "baseModelDistribution": "Distribution des modèles de base",
"usageTrends": "Tendances d'utilisation (30 derniers jours)", "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": { "modals": {
@@ -1032,7 +1135,10 @@
"titleWithType": "Télécharger {type} depuis une URL", "titleWithType": "Télécharger {type} depuis une URL",
"civitaiUrl": "URL Civitai :", "civitaiUrl": "URL Civitai :",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "Entrez une URL CivitAI ou CivArchive par ligne. Prend en charge plusieurs URLs pour le téléchargement par lot.", "urlHint": "Entrez une URL CivitAI, CivArchive ou Hugging Face par ligne. Prend en charge plusieurs URL pour le téléchargement par lot.",
"selectHfFiles": "Sélectionnez le(s) fichier(s) à télécharger depuis ce dépôt :",
"selectAll": "Tout sélectionner",
"fetchingRepoFiles": "Récupération des fichiers du dépôt...",
"locationPreview": "Aperçu de l'emplacement de téléchargement", "locationPreview": "Aperçu de l'emplacement de téléchargement",
"useDefaultPath": "Utiliser le chemin par défaut", "useDefaultPath": "Utiliser le chemin par défaut",
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés", "useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
@@ -1061,7 +1167,9 @@
}, },
"errors": { "errors": {
"invalidUrl": "Format d'URL Civitai invalide", "invalidUrl": "Format d'URL Civitai invalide",
"noVersions": "Aucune version disponible pour ce modèle" "noVersions": "Aucune version disponible pour ce modèle",
"mixedSources": "Impossible de mélanger les URL CivitAI et Hugging Face dans le même lot.",
"noModelFiles": "Aucun fichier de modèle trouvé dans ce dépôt."
}, },
"status": { "status": {
"preparing": "Préparation du téléchargement...", "preparing": "Préparation du téléchargement...",
@@ -1212,6 +1320,8 @@
"editVersionName": "Modifier le nom de la version", "editVersionName": "Modifier le nom de la version",
"viewOnCivitai": "Voir sur Civitai", "viewOnCivitai": "Voir sur Civitai",
"viewOnCivitaiText": "Voir sur Civitai", "viewOnCivitaiText": "Voir sur Civitai",
"viewOnHuggingFace": "Voir sur Hugging Face",
"viewOnHuggingFaceText": "Voir sur Hugging Face",
"viewCreatorProfile": "Voir le profil du créateur", "viewCreatorProfile": "Voir le profil du créateur",
"openFileLocation": "Ouvrir l'emplacement du fichier", "openFileLocation": "Ouvrir l'emplacement du fichier",
"sendToWorkflow": "Envoyer vers ComfyUI", "sendToWorkflow": "Envoyer vers ComfyUI",
@@ -1237,7 +1347,10 @@
"additionalNotes": "Notes supplémentaires", "additionalNotes": "Notes supplémentaires",
"notesHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne", "notesHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne",
"addNotesPlaceholder": "Ajoutez vos notes ici...", "addNotesPlaceholder": "Ajoutez vos notes ici...",
"aboutThisVersion": "À propos de cette version" "aboutThisVersion": "À propos de cette version",
"baseModelSearchPlaceholder": "Rechercher un modèle de base…",
"baseModelSuggested": "Suggéré",
"baseModelNoMatch": "Aucun modèle de base correspondant"
}, },
"notes": { "notes": {
"saved": "Notes sauvegardées avec succès", "saved": "Notes sauvegardées avec succès",
@@ -1513,12 +1626,15 @@
"modelUpdated": "Modèle mis à jour dans le workflow", "modelUpdated": "Modèle mis à jour dans le workflow",
"modelFailed": "Échec de la mise à jour du nœud modèle", "modelFailed": "Échec de la mise à jour du nœud modèle",
"embeddingAdded": "Embedding ajouté au workflow", "embeddingAdded": "Embedding ajouté au workflow",
"embeddingFailed": "Échec de l'ajout de l'embedding" "embeddingFailed": "Échec de l'ajout de l'embedding",
"promptSent": "Prompt envoyé au workflow",
"promptFailed": "Échec de l'envoi du prompt"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Recipe", "recipe": "Recipe",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding", "embedding": "Embedding",
"prompt": "Prompt",
"replace": "Remplacer", "replace": "Remplacer",
"append": "Ajouter", "append": "Ajouter",
"selectTargetNode": "Sélectionner le nœud cible", "selectTargetNode": "Sélectionner le nœud cible",
@@ -1705,6 +1821,7 @@
"enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA", "enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA",
"reconnectedSuccessfully": "LoRA reconnecté avec succès", "reconnectedSuccessfully": "LoRA reconnecté avec succès",
"reconnectFailed": "Erreur lors de la reconnexion du LoRA : {message}", "reconnectFailed": "Erreur lors de la reconnexion du LoRA : {message}",
"noPromptToSend": "Aucun prompt à envoyer",
"cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant", "cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant",
"sendFailed": "Échec de l'envoi de la recipe vers le workflow", "sendFailed": "Échec de l'envoi de la recipe vers le workflow",
"sendError": "Erreur lors de l'envoi de la recipe vers le workflow", "sendError": "Erreur lors de l'envoi de la recipe vers le workflow",
+134 -17
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "הסר מהמועדפים", "removeFromFavorites": "הסר מהמועדפים",
"viewOnCivitai": "הצג ב-Civitai", "viewOnCivitai": "הצג ב-Civitai",
"notAvailableFromCivitai": "לא זמין מ-Civitai", "notAvailableFromCivitai": "לא זמין מ-Civitai",
"viewOnHuggingFace": "צפייה ב-Hugging Face",
"sendToWorkflow": "שלח ל-ComfyUI (לחיצה: הוסף, Shift+לחיצה: החלף)", "sendToWorkflow": "שלח ל-ComfyUI (לחיצה: הוסף, Shift+לחיצה: החלף)",
"copyLoRASyntax": "העתק תחביר LoRA", "copyLoRASyntax": "העתק תחביר LoRA",
"checkpointNameCopied": "שם Checkpoint הועתק", "checkpointNameCopied": "שם Checkpoint הועתק",
@@ -145,6 +146,10 @@
}, },
"usage": { "usage": {
"timesUsed": "מספר שימושים" "timesUsed": "מספר שימושים"
},
"footer": {
"versionCount": "{count} גרסאות",
"viewAllVersions": "הצג את כל הגרסאות המקומיות"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +188,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "ניהול מודלים מוחרגים" "label": "ניהול מודלים מוחרגים"
},
"groupByModel": {
"label": "קיבוץ לפי דגם"
} }
}, },
"header": { "header": {
@@ -195,13 +203,7 @@
"statistics": "סטטיסטיקה" "statistics": "סטטיסטיקה"
}, },
"search": { "search": {
"placeholder": פש...", "placeholder": יפוש",
"placeholders": {
"loras": "חפש LoRAs...",
"recipes": "חפש מתכונים...",
"checkpoints": "חפש checkpoints...",
"embeddings": "חפש embeddings..."
},
"options": "אפשרויות חיפוש", "options": "אפשרויות חיפוש",
"searchIn": "חפש ב:", "searchIn": "חפש ב:",
"notAvailable": "חיפוש לא זמין בדף הסטטיסטיקה", "notAvailable": "חיפוש לא זמין בדף הסטטיסטיקה",
@@ -251,7 +253,18 @@
"toggle": "החלף ערכת נושא", "toggle": "החלף ערכת נושא",
"switchToLight": "עבור לערכת נושא בהירה", "switchToLight": "עבור לערכת נושא בהירה",
"switchToDark": "עבור לערכת נושא כהה", "switchToDark": "עבור לערכת נושא כהה",
"switchToAuto": "עבור לערכת נושא אוטומטית" "switchToAuto": "עבור לערכת נושא אוטומטית",
"presets": "ערכות נושא מוגדרות",
"default": "ברירת מחדל",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "מצב",
"light": "בהיר",
"dark": "כהה",
"auto": "אוטומטי"
}, },
"actions": { "actions": {
"checkUpdates": "בדוק עדכונים", "checkUpdates": "בדוק עדכונים",
@@ -263,6 +276,9 @@
"civitaiApiKey": "מפתח API של Civitai", "civitaiApiKey": "מפתח API של Civitai",
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai", "civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai", "civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
"civitaiApiKeyConfigured": "מוגדר",
"civitaiApiKeyNotConfigured": "לא מוגדר",
"civitaiApiKeySet": "הגדר",
"civitaiHost": { "civitaiHost": {
"label": "מארח Civitai", "label": "מארח Civitai",
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".", "help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
@@ -303,6 +319,7 @@
"downloads": "הורדות", "downloads": "הורדות",
"videoSettings": "הגדרות וידאו", "videoSettings": "הגדרות וידאו",
"layoutSettings": "הגדרות פריסה", "layoutSettings": "הגדרות פריסה",
"licenseIcons": "סמלי רישיון",
"misc": "שונות", "misc": "שונות",
"backup": "גיבויים", "backup": "גיבויים",
"folderSettings": "תיקיות ברירת מחדל", "folderSettings": "תיקיות ברירת מחדל",
@@ -310,7 +327,7 @@
"extraFolderPaths": "נתיבי תיקיות נוספים", "extraFolderPaths": "נתיבי תיקיות נוספים",
"downloadPathTemplates": "תבניות נתיב הורדה", "downloadPathTemplates": "תבניות נתיב הורדה",
"priorityTags": "תגיות עדיפות", "priorityTags": "תגיות עדיפות",
"updateFlags": "תגי עדכון", "versionScope": "תגי עדכון",
"exampleImages": "תמונות דוגמה", "exampleImages": "תמונות דוגמה",
"autoOrganize": "ארגון אוטומטי", "autoOrganize": "ארגון אוטומטי",
"metadata": "מטא-נתונים", "metadata": "מטא-נתונים",
@@ -415,6 +432,8 @@
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה." "help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "קיבוץ לפי דגם",
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
"displayDensity": "צפיפות תצוגה", "displayDensity": "צפיפות תצוגה",
"displayDensityOptions": { "displayDensityOptions": {
"default": "ברירת מחדל", "default": "ברירת מחדל",
@@ -571,7 +590,7 @@
"download": "הורד", "download": "הורד",
"restartRequired": "דורש הפעלה מחדש" "restartRequired": "דורש הפעלה מחדש"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "אסטרטגיית תגי עדכון", "label": "אסטרטגיית תגי עדכון",
"help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.", "help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.",
"options": { "options": {
@@ -583,6 +602,10 @@
"label": "הסתר עדכוני גישה מוקדמת", "label": "הסתר עדכוני גישה מוקדמת",
"help": "רק עדכוני גישה מוקדמת" "help": "רק עדכוני גישה מוקדמת"
}, },
"licenseIcons": {
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
},
"misc": { "misc": {
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA", "includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח", "includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח",
@@ -651,7 +674,11 @@
"sizeAsc": "הקטן ביותר", "sizeAsc": "הקטן ביותר",
"usage": "מספר שימושים", "usage": "מספר שימושים",
"usageDesc": "הכי הרבה", "usageDesc": "הכי הרבה",
"usageAsc": "הכי פחות" "usageAsc": "הכי פחות",
"versionsCount": "גרסאות מקומיות",
"versionsCountDesc": "הכי הרבה גרסאות ראשונות",
"versionsCountAsc": "הכי מעט גרסאות ראשונות",
"versionIdDesc": "גרסה חדשה ביותר ראשונה"
}, },
"refresh": { "refresh": {
"title": "רענן רשימת מודלים", "title": "רענן רשימת מודלים",
@@ -997,6 +1024,18 @@
"storage": "אחסון", "storage": "אחסון",
"insights": "תובנות" "insights": "תובנות"
}, },
"metrics": {
"totalModels": "סה\"כ דגמים",
"totalStorage": "סה\"כ אחסון",
"totalGenerations": "סה\"כ יצירות",
"usageRate": "שיעור שימוש",
"loras": "LoRA",
"checkpoints": "נקודות ביקורת",
"embeddings": "הטמעות",
"uniqueTags": "תגיות ייחודיות",
"unusedModels": "דגמים שאינם בשימוש",
"avgUsesPerModel": "ממוצע שימושים/דגם"
},
"usage": { "usage": {
"mostUsedLoras": "LoRAs הנפוצים ביותר", "mostUsedLoras": "LoRAs הנפוצים ביותר",
"mostUsedCheckpoints": "Checkpoints הנפוצים ביותר", "mostUsedCheckpoints": "Checkpoints הנפוצים ביותר",
@@ -1014,13 +1053,77 @@
}, },
"insights": { "insights": {
"smartInsights": "תובנות חכמות", "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": { "charts": {
"collectionOverview": "סקירת אוסף", "collectionOverview": "סקירת אוסף",
"baseModelDistribution": "התפלגות מודלי בסיס", "baseModelDistribution": "התפלגות מודלי בסיס",
"usageTrends": "מגמות שימוש (30 יום אחרונים)", "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": { "modals": {
@@ -1032,7 +1135,10 @@
"titleWithType": "הורד {type} מכתובת URL", "titleWithType": "הורד {type} מכתובת URL",
"civitaiUrl": "כתובת URL של Civitai:", "civitaiUrl": "כתובת URL של Civitai:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "יש להזין כתובת URL אחת של CivitAI או CivArchive בכל שורה. תומך במספר כתובות URL להורדה בבת אחת.", "urlHint": "יש להזין כתובת URL אחת של CivitAI, CivArchive או Hugging Face בכל שורה. תומך במספר כתובות URL להורדה בקבוצה.",
"selectHfFiles": "בחר קבצים להורדה ממאגר זה:",
"selectAll": "בחר הכל",
"fetchingRepoFiles": "מביא קבצים מהמאגר...",
"locationPreview": "תצוגה מקדימה של מיקום ההורדה", "locationPreview": "תצוגה מקדימה של מיקום ההורדה",
"useDefaultPath": "השתמש בנתיב ברירת מחדל", "useDefaultPath": "השתמש בנתיב ברירת מחדל",
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות", "useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
@@ -1061,7 +1167,9 @@
}, },
"errors": { "errors": {
"invalidUrl": "פורמט URL של Civitai לא חוקי", "invalidUrl": "פורמט URL של Civitai לא חוקי",
"noVersions": "אין גרסאות זמינות למודל זה" "noVersions": "אין גרסאות זמינות למודל זה",
"mixedSources": "לא ניתן לערבב כתובות URL של CivitAI ו-Hugging Face באותה קבוצה.",
"noModelFiles": "לא נמצאו קבצי מודל במאגר זה."
}, },
"status": { "status": {
"preparing": "מכין הורדה...", "preparing": "מכין הורדה...",
@@ -1212,6 +1320,8 @@
"editVersionName": "ערוך שם גרסה", "editVersionName": "ערוך שם גרסה",
"viewOnCivitai": "הצג ב-Civitai", "viewOnCivitai": "הצג ב-Civitai",
"viewOnCivitaiText": "הצג ב-Civitai", "viewOnCivitaiText": "הצג ב-Civitai",
"viewOnHuggingFace": "צפייה ב-Hugging Face",
"viewOnHuggingFaceText": "צפייה ב-Hugging Face",
"viewCreatorProfile": "הצג פרופיל יוצר", "viewCreatorProfile": "הצג פרופיל יוצר",
"openFileLocation": "פתח מיקום קובץ", "openFileLocation": "פתח מיקום קובץ",
"sendToWorkflow": "שלח ל-ComfyUI", "sendToWorkflow": "שלח ל-ComfyUI",
@@ -1237,7 +1347,10 @@
"additionalNotes": "הערות נוספות", "additionalNotes": "הערות נוספות",
"notesHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה", "notesHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
"addNotesPlaceholder": "הוסף את ההערות שלך כאן...", "addNotesPlaceholder": "הוסף את ההערות שלך כאן...",
"aboutThisVersion": "אודות גרסה זו" "aboutThisVersion": "אודות גרסה זו",
"baseModelSearchPlaceholder": "חפש מודל בסיס…",
"baseModelSuggested": "מוצע",
"baseModelNoMatch": "אין מודלי בסיס תואמים"
}, },
"notes": { "notes": {
"saved": "הערות נשמרו בהצלחה", "saved": "הערות נשמרו בהצלחה",
@@ -1513,12 +1626,15 @@
"modelUpdated": "מודל עודכן ב-workflow", "modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל", "modelFailed": "עדכון צומת המודל נכשל",
"embeddingAdded": "Embedding נוסף ל-workflow", "embeddingAdded": "Embedding נוסף ל-workflow",
"embeddingFailed": "הוספת Embedding נכשלה" "embeddingFailed": "הוספת Embedding נכשלה",
"promptSent": "הנחיה נשלחה ל-workflow",
"promptFailed": "שליחת ההנחיה נכשלה"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "מתכון", "recipe": "מתכון",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding", "embedding": "Embedding",
"prompt": "הנחיה",
"replace": "החלף", "replace": "החלף",
"append": "הוסף", "append": "הוסף",
"selectTargetNode": "בחר צומת יעד", "selectTargetNode": "בחר צומת יעד",
@@ -1705,6 +1821,7 @@
"enterLoraName": "אנא הזן שם LoRA או תחביר", "enterLoraName": "אנא הזן שם LoRA או תחביר",
"reconnectedSuccessfully": "LoRA קושר מחדש בהצלחה", "reconnectedSuccessfully": "LoRA קושר מחדש בהצלחה",
"reconnectFailed": "שגיאה בקישור מחדש של LoRA: {message}", "reconnectFailed": "שגיאה בקישור מחדש של LoRA: {message}",
"noPromptToSend": "אין הנחיה לשליחה",
"cannotSend": "לא ניתן לשלוח מתכון: חסר מזהה מתכון", "cannotSend": "לא ניתן לשלוח מתכון: חסר מזהה מתכון",
"sendFailed": "שליחת המתכון ל-workflow נכשלה", "sendFailed": "שליחת המתכון ל-workflow נכשלה",
"sendError": "שגיאה בשליחת המתכון ל-workflow", "sendError": "שגיאה בשליחת המתכון ל-workflow",
+134 -17
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "お気に入りから削除", "removeFromFavorites": "お気に入りから削除",
"viewOnCivitai": "Civitaiで表示", "viewOnCivitai": "Civitaiで表示",
"notAvailableFromCivitai": "Civitaiでは利用できません", "notAvailableFromCivitai": "Civitaiでは利用できません",
"viewOnHuggingFace": "Hugging Face で見る",
"sendToWorkflow": "ComfyUIに送信(クリック:追加、Shift+クリック:置換)", "sendToWorkflow": "ComfyUIに送信(クリック:追加、Shift+クリック:置換)",
"copyLoRASyntax": "LoRA構文をコピー", "copyLoRASyntax": "LoRA構文をコピー",
"checkpointNameCopied": "checkpointの名前をコピーしました", "checkpointNameCopied": "checkpointの名前をコピーしました",
@@ -145,6 +146,10 @@
}, },
"usage": { "usage": {
"timesUsed": "使用回数" "timesUsed": "使用回数"
},
"footer": {
"versionCount": "{count} バージョン",
"viewAllVersions": "ローカルの全バージョンを表示"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +188,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "除外モデルを管理" "label": "除外モデルを管理"
},
"groupByModel": {
"label": "モデルでグループ化"
} }
}, },
"header": { "header": {
@@ -195,13 +203,7 @@
"statistics": "統計" "statistics": "統計"
}, },
"search": { "search": {
"placeholder": "検索...", "placeholder": "検索",
"placeholders": {
"loras": "LoRAを検索...",
"recipes": "レシピを検索...",
"checkpoints": "checkpointを検索...",
"embeddings": "embeddingを検索..."
},
"options": "検索オプション", "options": "検索オプション",
"searchIn": "検索対象:", "searchIn": "検索対象:",
"notAvailable": "統計ページでは検索は利用できません", "notAvailable": "統計ページでは検索は利用できません",
@@ -251,7 +253,18 @@
"toggle": "テーマの切り替え", "toggle": "テーマの切り替え",
"switchToLight": "ライトテーマに切り替え", "switchToLight": "ライトテーマに切り替え",
"switchToDark": "ダークテーマに切り替え", "switchToDark": "ダークテーマに切り替え",
"switchToAuto": "自動テーマに切り替え" "switchToAuto": "自動テーマに切り替え",
"presets": "テーマプリセット",
"default": "デフォルト",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "モード",
"light": "ライト",
"dark": "ダーク",
"auto": "自動"
}, },
"actions": { "actions": {
"checkUpdates": "更新確認", "checkUpdates": "更新確認",
@@ -263,6 +276,9 @@
"civitaiApiKey": "Civitai APIキー", "civitaiApiKey": "Civitai APIキー",
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください", "civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます", "civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
"civitaiApiKeyConfigured": "設定済み",
"civitaiApiKeyNotConfigured": "未設定",
"civitaiApiKeySet": "設定",
"civitaiHost": { "civitaiHost": {
"label": "Civitai ホスト", "label": "Civitai ホスト",
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。", "help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
@@ -303,6 +319,7 @@
"downloads": "ダウンロード", "downloads": "ダウンロード",
"videoSettings": "動画設定", "videoSettings": "動画設定",
"layoutSettings": "レイアウト設定", "layoutSettings": "レイアウト設定",
"licenseIcons": "ライセンスアイコン",
"misc": "その他", "misc": "その他",
"backup": "バックアップ", "backup": "バックアップ",
"folderSettings": "デフォルトルート", "folderSettings": "デフォルトルート",
@@ -310,7 +327,7 @@
"extraFolderPaths": "追加フォルダーパス", "extraFolderPaths": "追加フォルダーパス",
"downloadPathTemplates": "ダウンロードパステンプレート", "downloadPathTemplates": "ダウンロードパステンプレート",
"priorityTags": "優先タグ", "priorityTags": "優先タグ",
"updateFlags": "アップデートフラグ", "versionScope": "アップデートフラグ",
"exampleImages": "例画像", "exampleImages": "例画像",
"autoOrganize": "自動整理", "autoOrganize": "自動整理",
"metadata": "メタデータ", "metadata": "メタデータ",
@@ -415,6 +432,8 @@
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。" "help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "モデルでグループ化",
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
"displayDensity": "表示密度", "displayDensity": "表示密度",
"displayDensityOptions": { "displayDensityOptions": {
"default": "デフォルト", "default": "デフォルト",
@@ -571,7 +590,7 @@
"download": "ダウンロード", "download": "ダウンロード",
"restartRequired": "再起動が必要" "restartRequired": "再起動が必要"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "アップデートフラグの表示戦略", "label": "アップデートフラグの表示戦略",
"help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。", "help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。",
"options": { "options": {
@@ -583,6 +602,10 @@
"label": "早期アクセス更新を非表示", "label": "早期アクセス更新を非表示",
"help": "早期アクセスのみの更新" "help": "早期アクセスのみの更新"
}, },
"licenseIcons": {
"useNewStyle": "更新されたライセンスアイコンを使用",
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
},
"misc": { "misc": {
"includeTriggerWords": "LoRA構文にトリガーワードを含める", "includeTriggerWords": "LoRA構文にトリガーワードを含める",
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます", "includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます",
@@ -651,7 +674,11 @@
"sizeAsc": "小さい順", "sizeAsc": "小さい順",
"usage": "使用回数", "usage": "使用回数",
"usageDesc": "多い", "usageDesc": "多い",
"usageAsc": "少ない" "usageAsc": "少ない",
"versionsCount": "ローカルバージョン数",
"versionsCountDesc": "バージョン数の多い順",
"versionsCountAsc": "バージョン数の少ない順",
"versionIdDesc": "最新バージョン順"
}, },
"refresh": { "refresh": {
"title": "モデルリストを更新", "title": "モデルリストを更新",
@@ -997,6 +1024,18 @@
"storage": "ストレージ", "storage": "ストレージ",
"insights": "インサイト" "insights": "インサイト"
}, },
"metrics": {
"totalModels": "モデル総数",
"totalStorage": "ストレージ合計",
"totalGenerations": "生成回数合計",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "ユニークタグ",
"unusedModels": "未使用モデル",
"avgUsesPerModel": "平均使用回数/モデル"
},
"usage": { "usage": {
"mostUsedLoras": "最も使用されているLoRA", "mostUsedLoras": "最も使用されているLoRA",
"mostUsedCheckpoints": "最も使用されているCheckpoint", "mostUsedCheckpoints": "最も使用されているCheckpoint",
@@ -1014,13 +1053,77 @@
}, },
"insights": { "insights": {
"smartInsights": "スマートインサイト", "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": { "charts": {
"collectionOverview": "コレクション概要", "collectionOverview": "コレクション概要",
"baseModelDistribution": "ベースモデル分布", "baseModelDistribution": "ベースモデル分布",
"usageTrends": "使用傾向(過去30日)", "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": { "modals": {
@@ -1032,7 +1135,10 @@
"titleWithType": "URLから{type}をダウンロード", "titleWithType": "URLから{type}をダウンロード",
"civitaiUrl": "Civitai URL", "civitaiUrl": "Civitai URL",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "1行に1つのCivitAIまたはCivArchive URLを入力してください。複数のURLを一括ダウンロードできます。", "urlHint": "1行に1つのCivitAICivArchive、またはHugging Face URLを入力してください。複数のURLを一括ダウンロードできます。",
"selectHfFiles": "このリポジトリからダウンロードするファイルを選択してください:",
"selectAll": "すべて選択",
"fetchingRepoFiles": "リポジトリのファイルを取得中...",
"locationPreview": "ダウンロード場所プレビュー", "locationPreview": "ダウンロード場所プレビュー",
"useDefaultPath": "デフォルトパスを使用", "useDefaultPath": "デフォルトパスを使用",
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます", "useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
@@ -1061,7 +1167,9 @@
}, },
"errors": { "errors": {
"invalidUrl": "無効なCivitai URL形式", "invalidUrl": "無効なCivitai URL形式",
"noVersions": "このモデルの利用可能なバージョンがありません" "noVersions": "このモデルの利用可能なバージョンがありません",
"mixedSources": "同じバッチ内でCivitAIとHugging FaceのURLを混在させることはできません。",
"noModelFiles": "このリポジトリにモデルファイルが見つかりませんでした。"
}, },
"status": { "status": {
"preparing": "ダウンロードを準備中...", "preparing": "ダウンロードを準備中...",
@@ -1212,6 +1320,8 @@
"editVersionName": "バージョン名を編集", "editVersionName": "バージョン名を編集",
"viewOnCivitai": "Civitaiで表示", "viewOnCivitai": "Civitaiで表示",
"viewOnCivitaiText": "Civitaiで表示", "viewOnCivitaiText": "Civitaiで表示",
"viewOnHuggingFace": "Hugging Face で見る",
"viewOnHuggingFaceText": "Hugging Face で見る",
"viewCreatorProfile": "作成者プロフィールを表示", "viewCreatorProfile": "作成者プロフィールを表示",
"openFileLocation": "ファイルの場所を開く", "openFileLocation": "ファイルの場所を開く",
"sendToWorkflow": "ComfyUI に送信", "sendToWorkflow": "ComfyUI に送信",
@@ -1237,7 +1347,10 @@
"additionalNotes": "追加メモ", "additionalNotes": "追加メモ",
"notesHint": "Enterで保存、Shift+Enterで改行", "notesHint": "Enterで保存、Shift+Enterで改行",
"addNotesPlaceholder": "メモをここに追加...", "addNotesPlaceholder": "メモをここに追加...",
"aboutThisVersion": "このバージョンについて" "aboutThisVersion": "このバージョンについて",
"baseModelSearchPlaceholder": "ベースモデルを検索…",
"baseModelSuggested": "おすすめ",
"baseModelNoMatch": "該当するベースモデルがありません"
}, },
"notes": { "notes": {
"saved": "メモが正常に保存されました", "saved": "メモが正常に保存されました",
@@ -1513,12 +1626,15 @@
"modelUpdated": "モデルがワークフローで更新されました", "modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました", "modelFailed": "モデルノードの更新に失敗しました",
"embeddingAdded": "Embeddingをワークフローに追加しました", "embeddingAdded": "Embeddingをワークフローに追加しました",
"embeddingFailed": "Embeddingの追加に失敗しました" "embeddingFailed": "Embeddingの追加に失敗しました",
"promptSent": "プロンプトをワークフローに送信しました",
"promptFailed": "プロンプトの送信に失敗しました"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "レシピ", "recipe": "レシピ",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding", "embedding": "Embedding",
"prompt": "プロンプト",
"replace": "置換", "replace": "置換",
"append": "追加", "append": "追加",
"selectTargetNode": "ターゲットノードを選択", "selectTargetNode": "ターゲットノードを選択",
@@ -1705,6 +1821,7 @@
"enterLoraName": "LoRA名または構文を入力してください", "enterLoraName": "LoRA名または構文を入力してください",
"reconnectedSuccessfully": "LoRAが正常に再接続されました", "reconnectedSuccessfully": "LoRAが正常に再接続されました",
"reconnectFailed": "LoRA再接続エラー:{message}", "reconnectFailed": "LoRA再接続エラー:{message}",
"noPromptToSend": "送信するプロンプトがありません",
"cannotSend": "レシピを送信できません:レシピIDがありません", "cannotSend": "レシピを送信できません:レシピIDがありません",
"sendFailed": "レシピのワークフローへの送信に失敗しました", "sendFailed": "レシピのワークフローへの送信に失敗しました",
"sendError": "レシピのワークフロー送信エラー", "sendError": "レシピのワークフロー送信エラー",
+134 -17
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "즐겨찾기에서 제거", "removeFromFavorites": "즐겨찾기에서 제거",
"viewOnCivitai": "Civitai에서 보기", "viewOnCivitai": "Civitai에서 보기",
"notAvailableFromCivitai": "Civitai에서 사용할 수 없음", "notAvailableFromCivitai": "Civitai에서 사용할 수 없음",
"viewOnHuggingFace": "Hugging Face에서 보기",
"sendToWorkflow": "ComfyUI로 전송 (클릭: 추가, Shift+클릭: 교체)", "sendToWorkflow": "ComfyUI로 전송 (클릭: 추가, Shift+클릭: 교체)",
"copyLoRASyntax": "LoRA 문법 복사", "copyLoRASyntax": "LoRA 문법 복사",
"checkpointNameCopied": "Checkpoint 이름 복사됨", "checkpointNameCopied": "Checkpoint 이름 복사됨",
@@ -145,6 +146,10 @@
}, },
"usage": { "usage": {
"timesUsed": "사용 횟수" "timesUsed": "사용 횟수"
},
"footer": {
"versionCount": "{count}개 버전",
"viewAllVersions": "모든 로컬 버전 보기"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +188,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "제외된 모델 관리" "label": "제외된 모델 관리"
},
"groupByModel": {
"label": "모델별 그룹화"
} }
}, },
"header": { "header": {
@@ -195,13 +203,7 @@
"statistics": "통계" "statistics": "통계"
}, },
"search": { "search": {
"placeholder": "검색...", "placeholder": "검색",
"placeholders": {
"loras": "LoRA 검색...",
"recipes": "레시피 검색...",
"checkpoints": "Checkpoint 검색...",
"embeddings": "Embedding 검색..."
},
"options": "검색 옵션", "options": "검색 옵션",
"searchIn": "검색 범위:", "searchIn": "검색 범위:",
"notAvailable": "통계 페이지에서는 검색을 사용할 수 없습니다", "notAvailable": "통계 페이지에서는 검색을 사용할 수 없습니다",
@@ -251,7 +253,18 @@
"toggle": "테마 토글", "toggle": "테마 토글",
"switchToLight": "라이트 테마로 전환", "switchToLight": "라이트 테마로 전환",
"switchToDark": "다크 테마로 전환", "switchToDark": "다크 테마로 전환",
"switchToAuto": "자동 테마로 전환" "switchToAuto": "자동 테마로 전환",
"presets": "테마 프리셋",
"default": "기본",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "모드",
"light": "라이트",
"dark": "다크",
"auto": "자동"
}, },
"actions": { "actions": {
"checkUpdates": "업데이트 확인", "checkUpdates": "업데이트 확인",
@@ -263,6 +276,9 @@
"civitaiApiKey": "Civitai API 키", "civitaiApiKey": "Civitai API 키",
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요", "civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다", "civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
"civitaiApiKeyConfigured": "설정됨",
"civitaiApiKeyNotConfigured": "설정되지 않음",
"civitaiApiKeySet": "설정",
"civitaiHost": { "civitaiHost": {
"label": "Civitai 호스트", "label": "Civitai 호스트",
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.", "help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
@@ -303,6 +319,7 @@
"downloads": "다운로드", "downloads": "다운로드",
"videoSettings": "비디오 설정", "videoSettings": "비디오 설정",
"layoutSettings": "레이아웃 설정", "layoutSettings": "레이아웃 설정",
"licenseIcons": "라이선스 아이콘",
"misc": "기타", "misc": "기타",
"backup": "백업", "backup": "백업",
"folderSettings": "기본 루트", "folderSettings": "기본 루트",
@@ -310,7 +327,7 @@
"extraFolderPaths": "추가 폴다 경로", "extraFolderPaths": "추가 폴다 경로",
"downloadPathTemplates": "다운로드 경로 템플릿", "downloadPathTemplates": "다운로드 경로 템플릿",
"priorityTags": "우선순위 태그", "priorityTags": "우선순위 태그",
"updateFlags": "업데이트 표시", "versionScope": "업데이트 표시",
"exampleImages": "예시 이미지", "exampleImages": "예시 이미지",
"autoOrganize": "자동 정리", "autoOrganize": "자동 정리",
"metadata": "메타데이터", "metadata": "메타데이터",
@@ -415,6 +432,8 @@
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다." "help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "모델별 그룹화",
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
"displayDensity": "표시 밀도", "displayDensity": "표시 밀도",
"displayDensityOptions": { "displayDensityOptions": {
"default": "기본", "default": "기본",
@@ -571,7 +590,7 @@
"download": "다운로드", "download": "다운로드",
"restartRequired": "재시작 필요" "restartRequired": "재시작 필요"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "업데이트 표시 전략", "label": "업데이트 표시 전략",
"help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.", "help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.",
"options": { "options": {
@@ -583,6 +602,10 @@
"label": "얼리 액세스 업데이트 숨기기", "label": "얼리 액세스 업데이트 숨기기",
"help": "얼리 액세스 업데이트만" "help": "얼리 액세스 업데이트만"
}, },
"licenseIcons": {
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
},
"misc": { "misc": {
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함", "includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다", "includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다",
@@ -651,7 +674,11 @@
"sizeAsc": "작은 순서", "sizeAsc": "작은 순서",
"usage": "사용 횟수", "usage": "사용 횟수",
"usageDesc": "많은 순", "usageDesc": "많은 순",
"usageAsc": "적은 순" "usageAsc": "적은 순",
"versionsCount": "로컬 버전 수",
"versionsCountDesc": "버전 수 많은 순",
"versionsCountAsc": "버전 수 적은 순",
"versionIdDesc": "최신 버전순"
}, },
"refresh": { "refresh": {
"title": "모델 목록 새로고침", "title": "모델 목록 새로고침",
@@ -997,6 +1024,18 @@
"storage": "저장소", "storage": "저장소",
"insights": "인사이트" "insights": "인사이트"
}, },
"metrics": {
"totalModels": "모델 총계",
"totalStorage": "총 저장 공간",
"totalGenerations": "총 생성 횟수",
"usageRate": "사용률",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "고유 태그",
"unusedModels": "미사용 모델",
"avgUsesPerModel": "모델당 평균 사용"
},
"usage": { "usage": {
"mostUsedLoras": "가장 많이 사용된 LoRA", "mostUsedLoras": "가장 많이 사용된 LoRA",
"mostUsedCheckpoints": "가장 많이 사용된 Checkpoint", "mostUsedCheckpoints": "가장 많이 사용된 Checkpoint",
@@ -1014,13 +1053,77 @@
}, },
"insights": { "insights": {
"smartInsights": "스마트 인사이트", "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": { "charts": {
"collectionOverview": "컬렉션 개요", "collectionOverview": "컬렉션 개요",
"baseModelDistribution": "베이스 모델 분포", "baseModelDistribution": "베이스 모델 분포",
"usageTrends": "사용량 트렌드 (최근 30일)", "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": { "modals": {
@@ -1032,7 +1135,10 @@
"titleWithType": "URL에서 {type} 다운로드", "titleWithType": "URL에서 {type} 다운로드",
"civitaiUrl": "Civitai URL:", "civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "한 줄에 하나의 CivitAI 또는 CivArchive URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.", "urlHint": "한 줄에 하나의 CivitAI, CivArchive 또는 Hugging Face URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
"selectHfFiles": "이 저장소에서 다운로드할 파일을 선택하세요:",
"selectAll": "모두 선택",
"fetchingRepoFiles": "저장소 파일을 가져오는 중...",
"locationPreview": "다운로드 위치 미리보기", "locationPreview": "다운로드 위치 미리보기",
"useDefaultPath": "기본 경로 사용", "useDefaultPath": "기본 경로 사용",
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다", "useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
@@ -1061,7 +1167,9 @@
}, },
"errors": { "errors": {
"invalidUrl": "잘못된 Civitai URL 형식", "invalidUrl": "잘못된 Civitai URL 형식",
"noVersions": "이 모델에 사용 가능한 버전이 없습니다" "noVersions": "이 모델에 사용 가능한 버전이 없습니다",
"mixedSources": "동일한 배치에서 CivitAI와 Hugging Face URL을 혼합할 수 없습니다.",
"noModelFiles": "이 저장소에서 모델 파일을 찾을 수 없습니다."
}, },
"status": { "status": {
"preparing": "다운로드 준비 중...", "preparing": "다운로드 준비 중...",
@@ -1212,6 +1320,8 @@
"editVersionName": "버전명 편집", "editVersionName": "버전명 편집",
"viewOnCivitai": "Civitai에서 보기", "viewOnCivitai": "Civitai에서 보기",
"viewOnCivitaiText": "Civitai에서 보기", "viewOnCivitaiText": "Civitai에서 보기",
"viewOnHuggingFace": "Hugging Face에서 보기",
"viewOnHuggingFaceText": "Hugging Face에서 보기",
"viewCreatorProfile": "제작자 프로필 보기", "viewCreatorProfile": "제작자 프로필 보기",
"openFileLocation": "파일 위치 열기", "openFileLocation": "파일 위치 열기",
"sendToWorkflow": "ComfyUI로 보내기", "sendToWorkflow": "ComfyUI로 보내기",
@@ -1237,7 +1347,10 @@
"additionalNotes": "추가 메모", "additionalNotes": "추가 메모",
"notesHint": "Enter로 저장, Shift+Enter로 줄바꿈", "notesHint": "Enter로 저장, Shift+Enter로 줄바꿈",
"addNotesPlaceholder": "메모를 여기에 추가하세요...", "addNotesPlaceholder": "메모를 여기에 추가하세요...",
"aboutThisVersion": "이 버전에 대해" "aboutThisVersion": "이 버전에 대해",
"baseModelSearchPlaceholder": "베이스 모델 검색…",
"baseModelSuggested": "추천",
"baseModelNoMatch": "일치하는 베이스 모델 없음"
}, },
"notes": { "notes": {
"saved": "메모가 성공적으로 저장됨", "saved": "메모가 성공적으로 저장됨",
@@ -1513,12 +1626,15 @@
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다", "modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패", "modelFailed": "모델 노드 업데이트 실패",
"embeddingAdded": "Embedding을 워크플로에 추가했습니다", "embeddingAdded": "Embedding을 워크플로에 추가했습니다",
"embeddingFailed": "Embedding 추가 실패" "embeddingFailed": "Embedding 추가 실패",
"promptSent": "프롬프트를 워크플로에 보냈습니다",
"promptFailed": "프롬프트 보내기 실패"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "레시피", "recipe": "레시피",
"lora": "LoRA", "lora": "LoRA",
"embedding": "임베딩", "embedding": "임베딩",
"prompt": "프롬프트",
"replace": "교체", "replace": "교체",
"append": "추가", "append": "추가",
"selectTargetNode": "대상 노드 선택", "selectTargetNode": "대상 노드 선택",
@@ -1705,6 +1821,7 @@
"enterLoraName": "LoRA 이름 또는 문법을 입력해주세요", "enterLoraName": "LoRA 이름 또는 문법을 입력해주세요",
"reconnectedSuccessfully": "LoRA가 성공적으로 다시 연결되었습니다", "reconnectedSuccessfully": "LoRA가 성공적으로 다시 연결되었습니다",
"reconnectFailed": "LoRA 다시 연결 오류: {message}", "reconnectFailed": "LoRA 다시 연결 오류: {message}",
"noPromptToSend": "보낼 프롬프트가 없습니다",
"cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락", "cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락",
"sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다", "sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다",
"sendError": "레시피를 워크플로로 전송하는 중 오류", "sendError": "레시피를 워크플로로 전송하는 중 오류",
+134 -17
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "Удалить из избранного", "removeFromFavorites": "Удалить из избранного",
"viewOnCivitai": "Посмотреть на Civitai", "viewOnCivitai": "Посмотреть на Civitai",
"notAvailableFromCivitai": "Недоступно на Civitai", "notAvailableFromCivitai": "Недоступно на Civitai",
"viewOnHuggingFace": "Открыть Hugging Face",
"sendToWorkflow": "Отправить в ComfyUI (Клик: Добавить, Shift+Клик: Заменить)", "sendToWorkflow": "Отправить в ComfyUI (Клик: Добавить, Shift+Клик: Заменить)",
"copyLoRASyntax": "Копировать синтаксис LoRA", "copyLoRASyntax": "Копировать синтаксис LoRA",
"checkpointNameCopied": "Имя checkpoint скопировано", "checkpointNameCopied": "Имя checkpoint скопировано",
@@ -145,6 +146,10 @@
}, },
"usage": { "usage": {
"timesUsed": "Количество использований" "timesUsed": "Количество использований"
},
"footer": {
"versionCount": "{count} версий",
"viewAllVersions": "Показать все локальные версии"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +188,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Управление исключёнными моделями" "label": "Управление исключёнными моделями"
},
"groupByModel": {
"label": "Группировать по модели"
} }
}, },
"header": { "header": {
@@ -195,13 +203,7 @@
"statistics": "Статистика" "statistics": "Статистика"
}, },
"search": { "search": {
"placeholder": "Поиск...", "placeholder": "Поиск",
"placeholders": {
"loras": "Поиск LoRAs...",
"recipes": "Поиск рецептов...",
"checkpoints": "Поиск checkpoints...",
"embeddings": "Поиск embeddings..."
},
"options": "Опции поиска", "options": "Опции поиска",
"searchIn": "Искать в:", "searchIn": "Искать в:",
"notAvailable": "Поиск недоступен на странице статистики", "notAvailable": "Поиск недоступен на странице статистики",
@@ -251,7 +253,18 @@
"toggle": "Переключить тему", "toggle": "Переключить тему",
"switchToLight": "Переключить на светлую тему", "switchToLight": "Переключить на светлую тему",
"switchToDark": "Переключить на тёмную тему", "switchToDark": "Переключить на тёмную тему",
"switchToAuto": "Переключить на автоматическую тему" "switchToAuto": "Переключить на автоматическую тему",
"presets": "Предустановки тем",
"default": "По умолчанию",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "Режим",
"light": "Светлый",
"dark": "Тёмный",
"auto": "Авто"
}, },
"actions": { "actions": {
"checkUpdates": "Проверить обновления", "checkUpdates": "Проверить обновления",
@@ -263,6 +276,9 @@
"civitaiApiKey": "Ключ API Civitai", "civitaiApiKey": "Ключ API Civitai",
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai", "civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai", "civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
"civitaiApiKeyConfigured": "Настроен",
"civitaiApiKeyNotConfigured": "Не настроен",
"civitaiApiKeySet": "Настроить",
"civitaiHost": { "civitaiHost": {
"label": "Хост Civitai", "label": "Хост Civitai",
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».", "help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
@@ -303,6 +319,7 @@
"downloads": "Загрузки", "downloads": "Загрузки",
"videoSettings": "Настройки видео", "videoSettings": "Настройки видео",
"layoutSettings": "Настройки макета", "layoutSettings": "Настройки макета",
"licenseIcons": "Значки лицензии",
"misc": "Разное", "misc": "Разное",
"backup": "Резервные копии", "backup": "Резервные копии",
"folderSettings": "Корневые папки", "folderSettings": "Корневые папки",
@@ -310,7 +327,7 @@
"extraFolderPaths": "Дополнительные пути к папкам", "extraFolderPaths": "Дополнительные пути к папкам",
"downloadPathTemplates": "Шаблоны путей загрузки", "downloadPathTemplates": "Шаблоны путей загрузки",
"priorityTags": "Приоритетные теги", "priorityTags": "Приоритетные теги",
"updateFlags": "Метки обновлений", "versionScope": "Метки обновлений",
"exampleImages": "Примеры изображений", "exampleImages": "Примеры изображений",
"autoOrganize": "Автоорганизация", "autoOrganize": "Автоорганизация",
"metadata": "Метаданные", "metadata": "Метаданные",
@@ -415,6 +432,8 @@
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки." "help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "Группировать по модели",
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
"displayDensity": "Плотность отображения", "displayDensity": "Плотность отображения",
"displayDensityOptions": { "displayDensityOptions": {
"default": "По умолчанию", "default": "По умолчанию",
@@ -571,7 +590,7 @@
"download": "Загрузить", "download": "Загрузить",
"restartRequired": "Требует перезапуска" "restartRequired": "Требует перезапуска"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "Стратегия меток обновлений", "label": "Стратегия меток обновлений",
"help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.", "help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.",
"options": { "options": {
@@ -583,6 +602,10 @@
"label": "Скрыть обновления раннего доступа", "label": "Скрыть обновления раннего доступа",
"help": "Только обновления раннего доступа" "help": "Только обновления раннего доступа"
}, },
"licenseIcons": {
"useNewStyle": "Использовать обновлённые значки лицензии",
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
},
"misc": { "misc": {
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA", "includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена", "includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена",
@@ -651,7 +674,11 @@
"sizeAsc": "Наименьшим", "sizeAsc": "Наименьшим",
"usage": "Число использований", "usage": "Число использований",
"usageDesc": "Больше", "usageDesc": "Больше",
"usageAsc": "Меньше" "usageAsc": "Меньше",
"versionsCount": "Локальные версии",
"versionsCountDesc": "Сначала больше версий",
"versionsCountAsc": "Сначала меньше версий",
"versionIdDesc": "Сначала новые версии"
}, },
"refresh": { "refresh": {
"title": "Обновить список моделей", "title": "Обновить список моделей",
@@ -997,6 +1024,18 @@
"storage": "Хранение", "storage": "Хранение",
"insights": "Аналитика" "insights": "Аналитика"
}, },
"metrics": {
"totalModels": "Всего моделей",
"totalStorage": "Всего хранилища",
"totalGenerations": "Всего генераций",
"usageRate": "Коэффициент использования",
"loras": "LoRA",
"checkpoints": "Контрольные точки",
"embeddings": "Эмбеддинги",
"uniqueTags": "Уникальные теги",
"unusedModels": "Неиспользуемые модели",
"avgUsesPerModel": "Сред. использований/модель"
},
"usage": { "usage": {
"mostUsedLoras": "Наиболее используемые LoRAs", "mostUsedLoras": "Наиболее используемые LoRAs",
"mostUsedCheckpoints": "Наиболее используемые Checkpoints", "mostUsedCheckpoints": "Наиболее используемые Checkpoints",
@@ -1014,13 +1053,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Умная аналитика", "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": { "charts": {
"collectionOverview": "Обзор коллекции", "collectionOverview": "Обзор коллекции",
"baseModelDistribution": "Распределение базовых моделей", "baseModelDistribution": "Распределение базовых моделей",
"usageTrends": "Тенденции использования (за последние 30 дней)", "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": { "modals": {
@@ -1032,7 +1135,10 @@
"titleWithType": "Скачать {type} по URL", "titleWithType": "Скачать {type} по URL",
"civitaiUrl": "Civitai URL:", "civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "Введите один URL CivitAI или CivArchive в каждой строке. Поддерживается пакетная загрузка нескольких URL.", "urlHint": "Введите один URL CivitAI, CivArchive или Hugging Face в каждой строке. Поддерживает несколько URL для пакетной загрузки.",
"selectHfFiles": "Выберите файл(ы) для загрузки из этого репозитория:",
"selectAll": "Выбрать все",
"fetchingRepoFiles": "Получение файлов репозитория...",
"locationPreview": "Предпросмотр места загрузки", "locationPreview": "Предпросмотр места загрузки",
"useDefaultPath": "Использовать путь по умолчанию", "useDefaultPath": "Использовать путь по умолчанию",
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей", "useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
@@ -1061,7 +1167,9 @@
}, },
"errors": { "errors": {
"invalidUrl": "Неверный формат URL Civitai", "invalidUrl": "Неверный формат URL Civitai",
"noVersions": "Нет доступных версий для этой модели" "noVersions": "Нет доступных версий для этой модели",
"mixedSources": "Нельзя смешивать URL-адреса CivitAI и Hugging Face в одном пакете.",
"noModelFiles": "В этом репозитории не найдено файлов моделей."
}, },
"status": { "status": {
"preparing": "Подготовка загрузки...", "preparing": "Подготовка загрузки...",
@@ -1212,6 +1320,8 @@
"editVersionName": "Редактировать название версии", "editVersionName": "Редактировать название версии",
"viewOnCivitai": "Посмотреть на Civitai", "viewOnCivitai": "Посмотреть на Civitai",
"viewOnCivitaiText": "Посмотреть на Civitai", "viewOnCivitaiText": "Посмотреть на Civitai",
"viewOnHuggingFace": "Открыть Hugging Face",
"viewOnHuggingFaceText": "Открыть Hugging Face",
"viewCreatorProfile": "Посмотреть профиль создателя", "viewCreatorProfile": "Посмотреть профиль создателя",
"openFileLocation": "Открыть расположение файла", "openFileLocation": "Открыть расположение файла",
"sendToWorkflow": "Отправить в ComfyUI", "sendToWorkflow": "Отправить в ComfyUI",
@@ -1237,7 +1347,10 @@
"additionalNotes": "Дополнительные заметки", "additionalNotes": "Дополнительные заметки",
"notesHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки", "notesHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
"addNotesPlaceholder": "Добавьте ваши заметки здесь...", "addNotesPlaceholder": "Добавьте ваши заметки здесь...",
"aboutThisVersion": "Об этой версии" "aboutThisVersion": "Об этой версии",
"baseModelSearchPlaceholder": "Поиск базовой модели…",
"baseModelSuggested": "Предполагаемые",
"baseModelNoMatch": "Нет подходящих базовых моделей"
}, },
"notes": { "notes": {
"saved": "Заметки успешно сохранены", "saved": "Заметки успешно сохранены",
@@ -1513,12 +1626,15 @@
"modelUpdated": "Модель обновлена в workflow", "modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели", "modelFailed": "Не удалось обновить узел модели",
"embeddingAdded": "Embedding добавлен в workflow", "embeddingAdded": "Embedding добавлен в workflow",
"embeddingFailed": "Не удалось добавить embedding" "embeddingFailed": "Не удалось добавить embedding",
"promptSent": "Запрос отправлен в workflow",
"promptFailed": "Не удалось отправить запрос"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Рецепт", "recipe": "Рецепт",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Эмбеддинг", "embedding": "Эмбеддинг",
"prompt": "Запрос",
"replace": "Заменить", "replace": "Заменить",
"append": "Добавить", "append": "Добавить",
"selectTargetNode": "Выберите целевой узел", "selectTargetNode": "Выберите целевой узел",
@@ -1705,6 +1821,7 @@
"enterLoraName": "Пожалуйста, введите название LoRA или синтаксис", "enterLoraName": "Пожалуйста, введите название LoRA или синтаксис",
"reconnectedSuccessfully": "LoRA успешно переподключена", "reconnectedSuccessfully": "LoRA успешно переподключена",
"reconnectFailed": "Ошибка переподключения LoRA: {message}", "reconnectFailed": "Ошибка переподключения LoRA: {message}",
"noPromptToSend": "Нет запроса для отправки",
"cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта", "cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта",
"sendFailed": "Не удалось отправить рецепт в workflow", "sendFailed": "Не удалось отправить рецепт в workflow",
"sendError": "Ошибка отправки рецепта в workflow", "sendError": "Ошибка отправки рецепта в workflow",
+138 -21
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "从收藏移除", "removeFromFavorites": "从收藏移除",
"viewOnCivitai": "在 Civitai 查看", "viewOnCivitai": "在 Civitai 查看",
"notAvailableFromCivitai": "Civitai 上不可用", "notAvailableFromCivitai": "Civitai 上不可用",
"viewOnHuggingFace": "在 Hugging Face 查看",
"sendToWorkflow": "发送到 ComfyUI(点击:追加,Shift+点击:替换)", "sendToWorkflow": "发送到 ComfyUI(点击:追加,Shift+点击:替换)",
"copyLoRASyntax": "复制 LoRA 语法", "copyLoRASyntax": "复制 LoRA 语法",
"checkpointNameCopied": "检查点名称已复制", "checkpointNameCopied": "检查点名称已复制",
@@ -145,6 +146,10 @@
}, },
"usage": { "usage": {
"timesUsed": "使用次数" "timesUsed": "使用次数"
},
"footer": {
"versionCount": "{count} 个版本",
"viewAllVersions": "查看所有本地版本"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +188,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "管理已排除的模型" "label": "管理已排除的模型"
},
"groupByModel": {
"label": "按模型分组"
} }
}, },
"header": { "header": {
@@ -195,13 +203,7 @@
"statistics": "统计" "statistics": "统计"
}, },
"search": { "search": {
"placeholder": "搜索...", "placeholder": "搜索",
"placeholders": {
"loras": "搜索 LoRA...",
"recipes": "搜索配方...",
"checkpoints": "搜索 Checkpoint...",
"embeddings": "搜索 Embedding..."
},
"options": "搜索选项", "options": "搜索选项",
"searchIn": "搜索范围:", "searchIn": "搜索范围:",
"notAvailable": "统计页面不可用搜索", "notAvailable": "统计页面不可用搜索",
@@ -251,7 +253,18 @@
"toggle": "切换主题", "toggle": "切换主题",
"switchToLight": "切换到浅色主题", "switchToLight": "切换到浅色主题",
"switchToDark": "切换到深色主题", "switchToDark": "切换到深色主题",
"switchToAuto": "切换到自动主题" "switchToAuto": "切换到自动主题",
"presets": "主题预设",
"default": "默认",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "模式",
"light": "浅色",
"dark": "深色",
"auto": "自动"
}, },
"actions": { "actions": {
"checkUpdates": "检查更新", "checkUpdates": "检查更新",
@@ -263,6 +276,9 @@
"civitaiApiKey": "Civitai API 密钥", "civitaiApiKey": "Civitai API 密钥",
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥", "civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证", "civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
"civitaiApiKeyConfigured": "已配置",
"civitaiApiKeyNotConfigured": "未配置",
"civitaiApiKeySet": "设置",
"civitaiHost": { "civitaiHost": {
"label": "Civitai 站点", "label": "Civitai 站点",
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。", "help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
@@ -303,6 +319,7 @@
"downloads": "下载", "downloads": "下载",
"videoSettings": "视频设置", "videoSettings": "视频设置",
"layoutSettings": "布局设置", "layoutSettings": "布局设置",
"licenseIcons": "许可协议图标",
"misc": "其他", "misc": "其他",
"backup": "备份", "backup": "备份",
"folderSettings": "默认根目录", "folderSettings": "默认根目录",
@@ -310,7 +327,7 @@
"extraFolderPaths": "额外文件夹路径", "extraFolderPaths": "额外文件夹路径",
"downloadPathTemplates": "下载路径模板", "downloadPathTemplates": "下载路径模板",
"priorityTags": "优先标签", "priorityTags": "优先标签",
"updateFlags": "更新标记", "versionScope": "版本范围",
"exampleImages": "示例图片", "exampleImages": "示例图片",
"autoOrganize": "自动整理", "autoOrganize": "自动整理",
"metadata": "元数据", "metadata": "元数据",
@@ -415,6 +432,8 @@
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。" "help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "按模型分组",
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
"displayDensity": "显示密度", "displayDensity": "显示密度",
"displayDensityOptions": { "displayDensityOptions": {
"default": "默认", "default": "默认",
@@ -571,18 +590,22 @@
"download": "下载", "download": "下载",
"restartRequired": "需要重启" "restartRequired": "需要重启"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "更新标记策略", "label": "版本分组",
"help": "决定更新徽章是否仅在新版本与本地文件共享相同基础模型时显示,或只要该模型有任何更新版本就显示。", "help": "控制版本在 UI 中的分组方式:按基础模型分组或合并显示。同时影响更新徽章逻辑和版本列表的筛选行为。",
"options": { "options": {
"sameBase": "按基础模型匹配更新", "sameBase": "按基础模型分组",
"any": "显示任何可用更新" "any": "显示所有版本"
} }
}, },
"hideEarlyAccessUpdates": { "hideEarlyAccessUpdates": {
"label": "隐藏抢先体验更新", "label": "隐藏抢先体验更新",
"help": "抢先体验更新" "help": "抢先体验更新"
}, },
"licenseIcons": {
"useNewStyle": "使用新版许可协议图标",
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
},
"misc": { "misc": {
"includeTriggerWords": "复制 LoRA 语法时包含触发词", "includeTriggerWords": "复制 LoRA 语法时包含触发词",
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词", "includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词",
@@ -651,7 +674,11 @@
"sizeAsc": "最小", "sizeAsc": "最小",
"usage": "使用次数", "usage": "使用次数",
"usageDesc": "最多", "usageDesc": "最多",
"usageAsc": "最少" "usageAsc": "最少",
"versionsCount": "本地版本数",
"versionsCountDesc": "版本数从多到少",
"versionsCountAsc": "版本数从少到多",
"versionIdDesc": "最新版本优先"
}, },
"refresh": { "refresh": {
"title": "刷新模型列表", "title": "刷新模型列表",
@@ -997,6 +1024,18 @@
"storage": "存储", "storage": "存储",
"insights": "洞察" "insights": "洞察"
}, },
"metrics": {
"totalModels": "模型总数",
"totalStorage": "总存储空间",
"totalGenerations": "总生成次数",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "唯一标签",
"unusedModels": "未使用模型",
"avgUsesPerModel": "平均使用次数/模型"
},
"usage": { "usage": {
"mostUsedLoras": "最常用 LoRA", "mostUsedLoras": "最常用 LoRA",
"mostUsedCheckpoints": "最常用 Checkpoint", "mostUsedCheckpoints": "最常用 Checkpoint",
@@ -1014,13 +1053,77 @@
}, },
"insights": { "insights": {
"smartInsights": "智能洞察", "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": { "charts": {
"collectionOverview": "收藏概览", "collectionOverview": "收藏概览",
"baseModelDistribution": "基础模型分布", "baseModelDistribution": "基础模型分布",
"usageTrends": "使用趋势(最近30天)", "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": { "modals": {
@@ -1032,7 +1135,10 @@
"titleWithType": "从 URL 下载 {type}", "titleWithType": "从 URL 下载 {type}",
"civitaiUrl": "Civitai URL:", "civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "每行输入一个 CivitAICivArchive URL。支持批量下载多个 URL。", "urlHint": "每行输入一个 CivitAICivArchive 或 Hugging Face URL。支持批量下载多个 URL。",
"selectHfFiles": "选择从此仓库下载的文件:",
"selectAll": "全选",
"fetchingRepoFiles": "正在获取仓库文件...",
"locationPreview": "下载位置预览", "locationPreview": "下载位置预览",
"useDefaultPath": "使用默认路径", "useDefaultPath": "使用默认路径",
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理", "useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
@@ -1061,7 +1167,9 @@
}, },
"errors": { "errors": {
"invalidUrl": "无效的 Civitai URL 格式", "invalidUrl": "无效的 Civitai URL 格式",
"noVersions": "此模型没有可用版本" "noVersions": "此模型没有可用版本",
"mixedSources": "无法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
"noModelFiles": "在此仓库中未找到模型文件。"
}, },
"status": { "status": {
"preparing": "正在准备下载...", "preparing": "正在准备下载...",
@@ -1212,6 +1320,8 @@
"editVersionName": "编辑版本名称", "editVersionName": "编辑版本名称",
"viewOnCivitai": "在 Civitai 查看", "viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看", "viewOnCivitaiText": "在 Civitai 查看",
"viewOnHuggingFace": "在 Hugging Face 查看",
"viewOnHuggingFaceText": "在 Hugging Face 查看",
"viewCreatorProfile": "查看创作者主页", "viewCreatorProfile": "查看创作者主页",
"openFileLocation": "打开文件位置", "openFileLocation": "打开文件位置",
"sendToWorkflow": "发送到 ComfyUI", "sendToWorkflow": "发送到 ComfyUI",
@@ -1237,7 +1347,10 @@
"additionalNotes": "附加备注", "additionalNotes": "附加备注",
"notesHint": "回车保存,Shift+回车换行", "notesHint": "回车保存,Shift+回车换行",
"addNotesPlaceholder": "在此添加你的备注...", "addNotesPlaceholder": "在此添加你的备注...",
"aboutThisVersion": "关于此版本" "aboutThisVersion": "关于此版本",
"baseModelSearchPlaceholder": "搜索基础模型…",
"baseModelSuggested": "推荐",
"baseModelNoMatch": "没有匹配的基础模型"
}, },
"notes": { "notes": {
"saved": "备注保存成功", "saved": "备注保存成功",
@@ -1513,12 +1626,15 @@
"modelUpdated": "模型已更新到工作流", "modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型节点失败", "modelFailed": "更新模型节点失败",
"embeddingAdded": "Embedding 已追加到工作流", "embeddingAdded": "Embedding 已追加到工作流",
"embeddingFailed": "添加 Embedding 失败" "embeddingFailed": "添加 Embedding 失败",
"promptSent": "提示词已发送到工作流",
"promptFailed": "提示词发送失败"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "配方", "recipe": "配方",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding", "embedding": "Embedding",
"prompt": "提示词",
"replace": "替换", "replace": "替换",
"append": "追加", "append": "追加",
"selectTargetNode": "选择目标节点", "selectTargetNode": "选择目标节点",
@@ -1705,6 +1821,7 @@
"enterLoraName": "请输入 LoRA 名称或语法", "enterLoraName": "请输入 LoRA 名称或语法",
"reconnectedSuccessfully": "LoRA 重新连接成功", "reconnectedSuccessfully": "LoRA 重新连接成功",
"reconnectFailed": "LoRA 重新连接出错:{message}", "reconnectFailed": "LoRA 重新连接出错:{message}",
"noPromptToSend": "没有可发送的提示词",
"cannotSend": "无法发送配方:缺少配方 ID", "cannotSend": "无法发送配方:缺少配方 ID",
"sendFailed": "发送配方到工作流失败", "sendFailed": "发送配方到工作流失败",
"sendError": "发送配方到工作流出错", "sendError": "发送配方到工作流出错",
+134 -17
View File
@@ -105,6 +105,7 @@
"removeFromFavorites": "移除收藏", "removeFromFavorites": "移除收藏",
"viewOnCivitai": "在 Civitai 查看", "viewOnCivitai": "在 Civitai 查看",
"notAvailableFromCivitai": "Civitai 不提供", "notAvailableFromCivitai": "Civitai 不提供",
"viewOnHuggingFace": "在 Hugging Face 查看",
"sendToWorkflow": "傳送到 ComfyUI(點擊:附加,Shift+點擊:取代)", "sendToWorkflow": "傳送到 ComfyUI(點擊:附加,Shift+點擊:取代)",
"copyLoRASyntax": "複製 LoRA 語法", "copyLoRASyntax": "複製 LoRA 語法",
"checkpointNameCopied": "Checkpoint 名稱已複製", "checkpointNameCopied": "Checkpoint 名稱已複製",
@@ -145,6 +146,10 @@
}, },
"usage": { "usage": {
"timesUsed": "使用次數" "timesUsed": "使用次數"
},
"footer": {
"versionCount": "{count} 個版本",
"viewAllVersions": "檢視所有本地版本"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +188,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "管理已排除的模型" "label": "管理已排除的模型"
},
"groupByModel": {
"label": "按模型分組"
} }
}, },
"header": { "header": {
@@ -195,13 +203,7 @@
"statistics": "統計" "statistics": "統計"
}, },
"search": { "search": {
"placeholder": "搜尋...", "placeholder": "搜尋",
"placeholders": {
"loras": "搜尋 LoRA...",
"recipes": "搜尋配方...",
"checkpoints": "搜尋 checkpoint...",
"embeddings": "搜尋 embedding..."
},
"options": "搜尋選項", "options": "搜尋選項",
"searchIn": "搜尋範圍:", "searchIn": "搜尋範圍:",
"notAvailable": "統計頁面無法搜尋", "notAvailable": "統計頁面無法搜尋",
@@ -251,7 +253,18 @@
"toggle": "切換主題", "toggle": "切換主題",
"switchToLight": "切換至淺色主題", "switchToLight": "切換至淺色主題",
"switchToDark": "切換至深色主題", "switchToDark": "切換至深色主題",
"switchToAuto": "自動主題" "switchToAuto": "自動主題",
"presets": "主題預設",
"default": "預設",
"nord": "Nord",
"midnight": "Midnight",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "模式",
"light": "淺色",
"dark": "深色",
"auto": "自動"
}, },
"actions": { "actions": {
"checkUpdates": "檢查更新", "checkUpdates": "檢查更新",
@@ -263,6 +276,9 @@
"civitaiApiKey": "Civitai API 金鑰", "civitaiApiKey": "Civitai API 金鑰",
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰", "civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證", "civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
"civitaiApiKeyConfigured": "已設定",
"civitaiApiKeyNotConfigured": "未設定",
"civitaiApiKeySet": "設定",
"civitaiHost": { "civitaiHost": {
"label": "Civitai 站點", "label": "Civitai 站點",
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。", "help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
@@ -303,6 +319,7 @@
"downloads": "下載", "downloads": "下載",
"videoSettings": "影片設定", "videoSettings": "影片設定",
"layoutSettings": "版面設定", "layoutSettings": "版面設定",
"licenseIcons": "許可協議圖標",
"misc": "其他", "misc": "其他",
"backup": "備份", "backup": "備份",
"folderSettings": "預設根目錄", "folderSettings": "預設根目錄",
@@ -310,7 +327,7 @@
"extraFolderPaths": "額外資料夾路徑", "extraFolderPaths": "額外資料夾路徑",
"downloadPathTemplates": "下載路徑範本", "downloadPathTemplates": "下載路徑範本",
"priorityTags": "優先標籤", "priorityTags": "優先標籤",
"updateFlags": "更新標記", "versionScope": "版本範圍",
"exampleImages": "範例圖片", "exampleImages": "範例圖片",
"autoOrganize": "自動整理", "autoOrganize": "自動整理",
"metadata": "中繼資料", "metadata": "中繼資料",
@@ -415,6 +432,8 @@
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。" "help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "按模型分組",
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
"displayDensity": "顯示密度", "displayDensity": "顯示密度",
"displayDensityOptions": { "displayDensityOptions": {
"default": "預設", "default": "預設",
@@ -571,7 +590,7 @@
"download": "下載", "download": "下載",
"restartRequired": "需要重新啟動" "restartRequired": "需要重新啟動"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "更新標記策略", "label": "更新標記策略",
"help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。", "help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。",
"options": { "options": {
@@ -583,6 +602,10 @@
"label": "隱藏搶先體驗更新", "label": "隱藏搶先體驗更新",
"help": "搶先體驗更新" "help": "搶先體驗更新"
}, },
"licenseIcons": {
"useNewStyle": "使用新版許可協議圖標",
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
},
"misc": { "misc": {
"includeTriggerWords": "在 LoRA 語法中包含觸發詞", "includeTriggerWords": "在 LoRA 語法中包含觸發詞",
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞", "includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞",
@@ -651,7 +674,11 @@
"sizeAsc": "最小", "sizeAsc": "最小",
"usage": "使用次數", "usage": "使用次數",
"usageDesc": "最多", "usageDesc": "最多",
"usageAsc": "最少" "usageAsc": "最少",
"versionsCount": "本地版本數",
"versionsCountDesc": "版本數從多到少",
"versionsCountAsc": "版本數從少到多",
"versionIdDesc": "最新版本優先"
}, },
"refresh": { "refresh": {
"title": "重新整理模型列表", "title": "重新整理模型列表",
@@ -997,6 +1024,18 @@
"storage": "儲存空間", "storage": "儲存空間",
"insights": "洞察" "insights": "洞察"
}, },
"metrics": {
"totalModels": "模型總數",
"totalStorage": "總儲存空間",
"totalGenerations": "總生成次數",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "唯一標籤",
"unusedModels": "未使用模型",
"avgUsesPerModel": "平均使用次數/模型"
},
"usage": { "usage": {
"mostUsedLoras": "最常用的 LoRA", "mostUsedLoras": "最常用的 LoRA",
"mostUsedCheckpoints": "最常用的 Checkpoint", "mostUsedCheckpoints": "最常用的 Checkpoint",
@@ -1014,13 +1053,77 @@
}, },
"insights": { "insights": {
"smartInsights": "智慧洞察", "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": { "charts": {
"collectionOverview": "收藏總覽", "collectionOverview": "收藏總覽",
"baseModelDistribution": "基礎模型分布", "baseModelDistribution": "基礎模型分布",
"usageTrends": "使用趨勢(最近 30 天)", "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": { "modals": {
@@ -1032,7 +1135,10 @@
"titleWithType": "從網址下載 {type}", "titleWithType": "從網址下載 {type}",
"civitaiUrl": "Civitai 網址:", "civitaiUrl": "Civitai 網址:",
"placeholder": "https://civitai.com/models/...", "placeholder": "https://civitai.com/models/...",
"urlHint": "每行輸入一個 CivitAICivArchive URL。支援批量下載多個 URL。", "urlHint": "每行輸入一個 CivitAICivArchive 或 Hugging Face URL。支援批量下載多個 URL。",
"selectHfFiles": "選擇從此倉庫下載的檔案:",
"selectAll": "全選",
"fetchingRepoFiles": "正在獲取倉庫檔案...",
"locationPreview": "下載位置預覽", "locationPreview": "下載位置預覽",
"useDefaultPath": "使用預設路徑", "useDefaultPath": "使用預設路徑",
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理", "useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
@@ -1061,7 +1167,9 @@
}, },
"errors": { "errors": {
"invalidUrl": "Civitai 網址格式無效", "invalidUrl": "Civitai 網址格式無效",
"noVersions": "此模型無可用版本" "noVersions": "此模型無可用版本",
"mixedSources": "無法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
"noModelFiles": "在此倉庫中未找到模型檔案。"
}, },
"status": { "status": {
"preparing": "準備下載中...", "preparing": "準備下載中...",
@@ -1212,6 +1320,8 @@
"editVersionName": "編輯版本名稱", "editVersionName": "編輯版本名稱",
"viewOnCivitai": "在 Civitai 查看", "viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看", "viewOnCivitaiText": "在 Civitai 查看",
"viewOnHuggingFace": "在 Hugging Face 查看",
"viewOnHuggingFaceText": "在 Hugging Face 查看",
"viewCreatorProfile": "查看創作者個人檔案", "viewCreatorProfile": "查看創作者個人檔案",
"openFileLocation": "開啟檔案位置", "openFileLocation": "開啟檔案位置",
"sendToWorkflow": "傳送到 ComfyUI", "sendToWorkflow": "傳送到 ComfyUI",
@@ -1237,7 +1347,10 @@
"additionalNotes": "附加備註", "additionalNotes": "附加備註",
"notesHint": "按 Enter 儲存,Shift+Enter 換行", "notesHint": "按 Enter 儲存,Shift+Enter 換行",
"addNotesPlaceholder": "在此新增備註...", "addNotesPlaceholder": "在此新增備註...",
"aboutThisVersion": "關於此版本" "aboutThisVersion": "關於此版本",
"baseModelSearchPlaceholder": "搜尋基礎模型…",
"baseModelSuggested": "推薦",
"baseModelNoMatch": "沒有符合的基礎模型"
}, },
"notes": { "notes": {
"saved": "備註已儲存", "saved": "備註已儲存",
@@ -1513,12 +1626,15 @@
"modelUpdated": "模型已更新到工作流", "modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型節點失敗", "modelFailed": "更新模型節點失敗",
"embeddingAdded": "Embedding 已附加到工作流", "embeddingAdded": "Embedding 已附加到工作流",
"embeddingFailed": "傳送 Embedding 到工作流失敗" "embeddingFailed": "傳送 Embedding 到工作流失敗",
"promptSent": "提示詞已發送到工作流",
"promptFailed": "提示詞發送失敗"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "配方", "recipe": "配方",
"lora": "LoRA", "lora": "LoRA",
"embedding": "Embedding", "embedding": "Embedding",
"prompt": "提示詞",
"replace": "取代", "replace": "取代",
"append": "附加", "append": "附加",
"selectTargetNode": "選擇目標節點", "selectTargetNode": "選擇目標節點",
@@ -1705,6 +1821,7 @@
"enterLoraName": "請輸入 LoRA 名稱或語法", "enterLoraName": "請輸入 LoRA 名稱或語法",
"reconnectedSuccessfully": "LoRA 重新連結成功", "reconnectedSuccessfully": "LoRA 重新連結成功",
"reconnectFailed": "LoRA 重新連結錯誤:{message}", "reconnectFailed": "LoRA 重新連結錯誤:{message}",
"noPromptToSend": "沒有可發送的提示詞",
"cannotSend": "無法傳送配方:缺少配方 ID", "cannotSend": "無法傳送配方:缺少配方 ID",
"sendFailed": "傳送配方到工作流失敗", "sendFailed": "傳送配方到工作流失敗",
"sendError": "傳送配方到工作流錯誤", "sendError": "傳送配方到工作流錯誤",
+50
View File
@@ -901,6 +901,55 @@ class LoraLoaderManagerExtractor(NodeMetadataExtractor):
"node_id": node_id "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): class FluxGuidanceExtractor(NodeMetadataExtractor):
@staticmethod @staticmethod
def extract(node_id, inputs, outputs, metadata): def extract(node_id, inputs, outputs, metadata):
@@ -1146,6 +1195,7 @@ NODE_EXTRACTORS = {
"UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager "UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager
"LoraLoader": LoraLoaderExtractor, "LoraLoader": LoraLoaderExtractor,
"LoraLoaderLM": LoraLoaderManagerExtractor, "LoraLoaderLM": LoraLoaderManagerExtractor,
"LoraTextLoaderLM": LoraTextLoaderManagerExtractor,
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor, "RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
"TensorRTLoader": TensorRTLoaderExtractor, "TensorRTLoader": TensorRTLoaderExtractor,
# Conditioning # Conditioning
+1 -1
View File
@@ -608,7 +608,7 @@ class SaveImageLM:
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
# Generate filename with counter if needed # Generate filename with counter if needed
base_filename = filename base_filename = filename.replace("%batch_num%", str(i))
if add_counter_to_filename: if add_counter_to_filename:
# Use counter + i to ensure unique filenames for all images in batch # Use counter + i to ensure unique filenames for all images in batch
current_counter = counter + i current_counter = counter + i
+29 -12
View File
@@ -123,23 +123,38 @@ class AutomaticMetadataParser(RecipeMetadataParser):
if model_hash_from_hashes: if model_hash_from_hashes:
metadata["model_hash"] = model_hash_from_hashes metadata["model_hash"] = model_hash_from_hashes
# Extract Lora hashes in alternative format # Extract Lora hashes in alternative format.
# Run unconditionally (not just as fallback) so that
# non-empty hashes from Lora hashes fill in the gaps left
# by empty values in the Hashes JSON dict. Some WebUI
# builds write real hash values only to Lora hashes and
# leave the Hashes JSON values empty.
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section) lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
if not hashes_match and lora_hashes_match: if lora_hashes_match:
try: try:
lora_hashes_str = lora_hashes_match.group(1) lora_hashes_str = lora_hashes_match.group(1)
lora_hash_entries = lora_hashes_str.split(', ') lora_hash_entries = lora_hashes_str.split(', ')
# Initialize hashes dict if it doesn't exist
if "hashes" not in metadata:
metadata["hashes"] = {}
# Parse each lora hash entry (format: "name: hash") # Parse each lora hash entry (format: "name: hash")
for entry in lora_hash_entries: for entry in lora_hash_entries:
if ': ' in entry: if ': ' in entry:
lora_name, lora_hash = entry.split(': ', 1) lora_name, lora_hash = entry.split(': ', 1)
# Add as lora type in the same format as regular hashes lora_hash = lora_hash.strip()
metadata["hashes"][f"lora:{lora_name}"] = lora_hash.strip() if not lora_hash:
# Skip entries without a hash value
continue
# Initialize hashes dict if it doesn't exist
if "hashes" not in metadata:
metadata["hashes"] = {}
# Add as lora type in the same format as
# regular hashes. Only override an
# existing entry if its value is empty
# (Lora hashes is the more reliable
# source when Hashes JSON has blanks).
key = f"lora:{lora_name}"
existing = metadata["hashes"].get(key, "")
if not existing:
metadata["hashes"][key] = lora_hash
# Remove lora hashes from params section # Remove lora hashes from params section
params_section = params_section.replace(lora_hashes_match.group(0), '') params_section = params_section.replace(lora_hashes_match.group(0), '')
@@ -363,6 +378,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
if not hash_key.startswith(("lora:", "hypernet:")): if not hash_key.startswith(("lora:", "hypernet:")):
continue continue
# Skip entries without a hash value — they can't be
# resolved via CivitAI and would only produce a
# useless "Deleted" entry in the recipe.
if not lora_hash:
continue
lora_type, lora_name = hash_key.split(':', 1) lora_type, lora_name = hash_key.split(':', 1)
# Get weight from extranet tags if available, else default to 1.0 # Get weight from extranet tags if available, else default to 1.0
@@ -387,11 +408,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
# Try to get info from Civitai # Try to get info from Civitai
if metadata_provider: if metadata_provider:
try: try:
if lora_hash:
# If we have hash, use it for lookup
civitai_info = await metadata_provider.get_model_by_hash(lora_hash) civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
else:
civitai_info = None
populated_entry = await self.populate_lora_from_civitai( populated_entry = await self.populate_lora_from_civitai(
lora_entry, lora_entry,
+53 -5
View File
@@ -514,11 +514,21 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
result["loras"].append(lora_entry) result["loras"].append(lora_entry)
# Process modelVersionIds from Civitai image API # Process modelVersionIds from Civitai image API.
# These are model version IDs returned at root level when meta doesn't contain resources # These are version IDs returned at root level of the API response.
if "modelVersionIds" in metadata and isinstance( # When resources or civitaiResources are already present in metadata
metadata["modelVersionIds"], list # (which they are when ?withMeta=true is passed), those sections have
# complete hash/type information — modelVersionIds is a fallback for
# when meta is null and only the flat ID list is available. Skipping
# it here avoids duplicates: the same file hash often resolves to
# different version IDs via hash lookup (resources) vs the original
# version ID in modelVersionIds, and both paths would create entries.
if (
"modelVersionIds" in metadata
and isinstance(metadata["modelVersionIds"], list)
and not result.get("loras")
): ):
for version_id in metadata["modelVersionIds"]: for version_id in metadata["modelVersionIds"]:
version_id_str = str(version_id) version_id_str = str(version_id)
@@ -526,6 +536,13 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if version_id_str in added_loras: if version_id_str in added_loras:
continue continue
# Skip if this version ID is already the recipe's checkpoint
# (resolved earlier from embedded resources/Model hash,
# avoiding a duplicate CivitAI API call).
existing_model = result.get("model")
if existing_model and str(existing_model.get("id")) == version_id_str:
continue
# Initialize lora entry with version ID # Initialize lora entry with version ID
lora_entry = { lora_entry = {
"id": version_id, "id": version_id,
@@ -559,9 +576,40 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
) )
if populated_entry is None: if populated_entry is None:
continue # Skip invalid LoRA types # Not a LoRA — try as checkpoint (only if we
# don't already have one). Reuses the same
# civitai_info from the API call above so no
# extra query is made.
if result["model"] is None:
checkpoint_entry = {
"id": version_id,
"modelId": 0,
"name": "Unknown Model",
"version": "",
"type": "checkpoint",
"existsLocally": False,
"localPath": None,
"file_name": "",
"hash": "",
"thumbnailUrl": (
"/loras_static/images/no-preview.png"
),
"baseModel": "",
"size": 0,
"downloadUrl": "",
"isDeleted": False,
}
cp_populated = await (
self.populate_checkpoint_from_civitai(
checkpoint_entry, civitai_info
)
)
if cp_populated.get("modelId"):
result["model"] = cp_populated
continue # Not a LoRA, don't add to loras
lora_entry = populated_entry lora_entry = populated_entry
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error fetching Civitai info for model version {version_id}: {e}" f"Error fetching Civitai info for model version {version_id}: {e}"
+409
View File
@@ -0,0 +1,409 @@
"""Handlers for Hugging Face model listing and download.
Minimal MVP implementation — uses direct HTTP to the HF API for file
listing and the project's existing aiohttp-based Downloader for
downloading. No huggingface_hub dependency required.
"""
from __future__ import annotations
import json
import logging
import os
import re
from typing import Any
import aiohttp
from aiohttp import web
from ...config import config
from ...services.downloader import (
DownloadProgress,
get_downloader,
)
from ...services.aria2_downloader import Aria2Downloader
from ...services.settings_manager import get_settings_manager
from ...services.service_registry import ServiceRegistry
from ...services.websocket_manager import ws_manager
from ...utils.constants import MODEL_FILE_EXTENSIONS
from ...utils.metadata_manager import MetadataManager
from ...utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
logger = logging.getLogger(__name__)
_DEFAULT_MODEL_CLASS = LoraMetadata
_DEFAULT_SCANNER_GETTER = "get_lora_scanner"
# Shared aiohttp session for HF API calls (created on first use)
_hf_api_session: aiohttp.ClientSession | None = None
async def _get_hf_api_session() -> aiohttp.ClientSession:
"""Get or create the shared aiohttp session for HF API calls."""
global _hf_api_session # needed because we reassign the module-level name
if _hf_api_session is None or _hf_api_session.closed:
_hf_api_session = aiohttp.ClientSession(
headers={"User-Agent": "ComfyUI-LoRA-Manager/1.0"},
timeout=aiohttp.ClientTimeout(total=30),
)
return _hf_api_session
def _infer_model_type(model_root: str) -> tuple[Any, str]:
"""Determine model class and scanner by matching ``model_root`` against the
configured root paths for each model type (from ``Config``).
The ``model_root`` value comes from the frontend's model-root dropdown,
which is populated from the current page's scanner roots. By checking
which scanner's root list it belongs to, we avoid fragile heuristics
like substring-matching path names.
"""
norm = os.path.normpath(model_root).replace(os.sep, "/")
# LoRA roots
for p in (config.loras_roots or []) + (config.extra_loras_roots or []):
if os.path.normpath(p).replace(os.sep, "/") == norm:
return LoraMetadata, "get_lora_scanner"
# Checkpoint / UNet roots
for p in (
(config.checkpoints_roots or [])
+ (config.extra_checkpoints_roots or [])
+ (config.unet_roots or [])
+ (config.extra_unet_roots or [])
):
if os.path.normpath(p).replace(os.sep, "/") == norm:
return CheckpointMetadata, "get_checkpoint_scanner"
# Embedding roots
for p in (config.embeddings_roots or []) + (config.extra_embeddings_roots or []):
if os.path.normpath(p).replace(os.sep, "/") == norm:
return EmbeddingMetadata, "get_embedding_scanner"
# Fallback — should not happen in normal use
logger.warning(
"Could not determine model type for root '%s'; defaulting to LoRA",
model_root,
)
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER
async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
"""Create a proper .metadata.json and add the model to the scanner cache.
Uses ``MetadataManager.create_default_metadata()`` which computes the
SHA256 hash, extracts safetensors header metadata (base_model), and
produces a fully-populated ``LoraMetadata`` (or ``CheckpointMetadata`` /
``EmbeddingMetadata``) object. We then overlay HF-specific fields and
register the model in the in-memory scanner cache so it appears
immediately without a full filesystem walk.
"""
try:
hf_url = f"https://huggingface.co/{repo}"
model_class, scanner_getter_name = _infer_model_type(model_root)
# 1. Create proper metadata (computes SHA256, reads safetensors headers)
metadata = await MetadataManager.create_default_metadata(
dest_path, model_class=model_class
)
if metadata is None:
logger.warning("create_default_metadata returned None for %s", dest_path)
return
# 2. Overlay HF-specific fields
metadata._unknown_fields["hf_url"] = hf_url
metadata.from_civitai = False # HF models are not from CivitAI
# 3. Save metadata atomically
await MetadataManager.save_metadata(dest_path, metadata)
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
# 4. Determine relative folder path for cache
# model_root is an absolute path; dest_path is under it
folder = ""
if os.path.isabs(model_root) and dest_path.startswith(model_root):
rel = os.path.relpath(os.path.dirname(dest_path), model_root)
folder = rel.replace(os.sep, "/") if rel != "." else ""
# 5. Add to scanner cache (same as CivitAI's _execute_download does)
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
if scanner_getter is not None:
scanner = await scanner_getter()
if scanner is not None:
metadata_dict = metadata.to_dict()
metadata_dict["hf_url"] = hf_url
await scanner.add_model_to_cache(metadata_dict, folder)
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
except Exception as exc:
logger.warning("Failed to save HF metadata for %s: %s", dest_path, exc)
class HfHandler:
"""Handle Hugging Face model browsing and download."""
async def get_hf_repo_files(self, request: web.Request) -> web.Response:
"""List model-weight files from a HF repo with real file sizes.
Uses the HF tree API endpoint which returns accurate file sizes
(including LFS-tracked files), unlike the model info endpoint.
"""
repo = request.query.get("repo", "").strip()
if not repo or "/" not in repo:
return web.json_response(
{"error": "Missing or invalid 'repo' parameter (expected user/repo)"},
status=400,
)
url = f"https://huggingface.co/api/models/{repo}/tree/main"
try:
session = await _get_hf_api_session()
async with session.get(url) as resp:
if resp.status == 404:
return web.json_response(
{"error": f"Repo '{repo}' not found"}, status=404
)
if resp.status != 200:
text = await resp.text()
return web.json_response(
{"error": f"HF API error {resp.status}: {text[:200]}"},
status=resp.status,
)
tree: list[dict[str, Any]] = await resp.json()
except Exception as exc:
logger.error("Failed to fetch HF repo files: %s", exc)
return web.json_response({"error": str(exc)}, status=502)
files: list[dict[str, Any]] = []
for entry in tree:
path: str = entry.get("path", "")
ext = os.path.splitext(path)[1].lower()
if ext not in MODEL_FILE_EXTENSIONS:
continue
size = entry.get("size", 0) or 0
if size == 0 and "lfs" in entry:
size = entry["lfs"].get("size", 0) or 0
files.append({
"filename": path,
"size": size,
})
files.sort(key=lambda f: f["size"], reverse=True)
return web.json_response(files)
async def download_hf_model(self, request: web.Request) -> web.Response:
"""Download a single file from Hugging Face into the model directory.
POST JSON body::
{
"repo": "dx8152/Flux2-Klein-9B-Consistency",
"filename": "Flux2-Klein-9B-consistency-V2.safetensors",
"revision": "main",
"model_root": "loras",
"relative_path": "",
"use_default_paths": false,
"download_id": "optional-batch-id"
}
If ``download_id`` is provided, real-time progress (bytes, speed,
percentage) is broadcast via the WebSocket progress system, matching
the CivitAI download experience.
Respects the ``download_backend`` setting (``aria2`` or ``default``).
"""
try:
payload: dict[str, Any] = await request.json()
except json.JSONDecodeError:
return web.json_response({"error": "Invalid JSON"}, status=400)
repo = (payload.get("repo") or "").strip()
filename = (payload.get("filename") or "").strip()
revision = (payload.get("revision") or "main").strip()
model_root = (payload.get("model_root") or "").strip()
relative_path = (payload.get("relative_path") or "").strip()
use_default_paths = bool(payload.get("use_default_paths", False))
download_id: str | None = payload.get("download_id")
logger.info(
"download_hf_model: repo=%s file=%s root=%s download_id=%s",
repo, filename, model_root, download_id,
)
if not repo or not filename:
return web.json_response(
{"error": "Missing required fields: 'repo' and 'filename'"}, status=400
)
# Validate repo format — must be user/repo_name
if repo.count("/") != 1 or not re.match(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", repo):
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
author, repo_name = repo.split("/", 1)
if ".." in (author, repo_name) or "." in (author, repo_name):
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
# Validate filename — must not contain path separators or ..
if "/" in filename or "\\" in filename or ".." in filename:
return web.json_response({"error": "Invalid filename"}, status=400)
# Validate relative_path — must not be absolute or escape base directory
if relative_path:
if os.path.isabs(relative_path):
return web.json_response({"error": "relative_path must not be absolute"}, status=400)
if ".." in relative_path.split("/") or "\\" in relative_path:
return web.json_response({"error": "Invalid relative_path"}, status=400)
# Validate model_root — must not contain path traversal
if not os.path.isabs(model_root):
# For relative model_root, check it doesn't escape
resolved_model_root = os.path.realpath(
os.path.join(os.getcwd(), "models", model_root)
)
else:
resolved_model_root = os.path.realpath(model_root)
# Verify model_root is within a configured scanner root
allowed_roots = set()
for root_list in (
config.loras_roots or [],
config.extra_loras_roots or [],
config.checkpoints_roots or [],
config.extra_checkpoints_roots or [],
config.unet_roots or [],
config.extra_unet_roots or [],
config.embeddings_roots or [],
config.extra_embeddings_roots or [],
):
for r in root_list:
allowed_roots.add(os.path.realpath(r))
if not any(resolved_model_root == root or resolved_model_root.startswith(root + os.sep) for root in allowed_roots):
logger.warning("Invalid model_root rejected: %s", model_root)
return web.json_response({"error": f"Invalid model_root: {model_root}"}, status=400)
base_dir = resolved_model_root
if use_default_paths:
target_dir = os.path.join(base_dir, "huggingface", author, repo_name)
elif relative_path:
target_dir = os.path.join(base_dir, relative_path)
else:
target_dir = base_dir
os.makedirs(target_dir, exist_ok=True)
dest_path = os.path.join(target_dir, filename)
# Resolve symlinks and check for path traversal escape
real_dest = os.path.realpath(dest_path)
real_base = os.path.realpath(target_dir)
if not real_dest.startswith(real_base + os.sep):
logger.warning("Path traversal blocked: %s -> %s", dest_path, real_dest)
return web.json_response({"error": "Path traversal detected"}, status=400)
# Check if already exists (simple skip)
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
logger.info("download_hf_model: file already exists, skipping — %s", dest_path)
return web.json_response({
"success": True,
"message": f"File already exists: {dest_path}",
"path": dest_path,
})
# Build HF resolve URL
resolve_url = (
f"https://huggingface.co/{repo}/resolve/{revision}/{filename}"
)
# Set up progress callback if download_id is provided
progress_callback = None
if download_id:
async def _progress_callback(
progress: float | DownloadProgress,
snapshot: DownloadProgress | None = None,
) -> None:
percent = 0.0
metrics = snapshot if isinstance(snapshot, DownloadProgress) else None
if isinstance(progress, DownloadProgress):
percent = progress.percent_complete
metrics = progress
elif isinstance(snapshot, DownloadProgress):
percent = snapshot.percent_complete
else:
percent = float(progress)
broadcast: dict[str, Any] = {
"status": "progress",
"progress": round(percent),
}
if metrics:
broadcast["bytes_downloaded"] = metrics.bytes_downloaded
broadcast["total_bytes"] = metrics.total_bytes
broadcast["bytes_per_second"] = metrics.bytes_per_second
await ws_manager.broadcast_download_progress(download_id, broadcast)
progress_callback = _progress_callback
# Respect download backend setting (aria2 vs default)
download_backend = (
get_settings_manager().get("download_backend", "default")
)
if download_backend == "aria2":
aria2 = await Aria2Downloader.get_instance()
aid = download_id or f"hf_{repo}_{filename}"
try:
hf_success, hf_result = await aria2.download_file(
url=resolve_url,
save_path=dest_path,
download_id=aid,
progress_callback=progress_callback,
)
if hf_success:
await _save_hf_metadata(dest_path, repo, model_root)
return web.json_response({
"success": True,
"message": f"Downloaded to {dest_path}",
"path": dest_path,
})
else:
return web.json_response(
{"success": False, "error": hf_result or "aria2 download failed"},
status=500,
)
except Exception as exc:
logger.error("HF download (aria2) failed: %s", exc)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
# Default: use built-in aiohttp Downloader
downloader = await get_downloader()
try:
success, result = await downloader.download_file(
url=resolve_url,
save_path=dest_path,
use_auth=False,
allow_resume=True,
progress_callback=progress_callback,
)
if success:
await _save_hf_metadata(dest_path, repo, model_root)
return web.json_response({
"success": True,
"message": f"Downloaded to {result}",
"path": result,
})
else:
return web.json_response(
{"success": False, "error": result or "Download failed"},
status=500,
)
except Exception as exc:
logger.error("HF download failed: %s", exc)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
+159 -32
View File
@@ -48,8 +48,12 @@ from ...utils.constants import (
SUPPORTED_MEDIA_EXTENSIONS, SUPPORTED_MEDIA_EXTENSIONS,
VALID_LORA_TYPES, VALID_LORA_TYPES,
) )
from .hf_handlers import HfHandler
from ...utils.civitai_utils import rewrite_preview_url 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.lora_metadata import extract_trained_words
from ...utils.session_logging import get_standalone_session_log_snapshot from ...utils.session_logging import get_standalone_session_log_snapshot
from ...utils.usage_stats import UsageStats from ...utils.usage_stats import UsageStats
@@ -411,9 +415,10 @@ class PromptServerProtocol(Protocol):
"""Subset of PromptServer used by the handlers.""" """Subset of PromptServer used by the handlers."""
instance: "PromptServerProtocol" instance: "PromptServerProtocol"
sockets: dict # maps clientId (sid) → WebSocketResponse
def send_sync( def send_sync(
self, event: str, payload: dict self, event: str, payload: dict | None = None, sid: str | None = None
) -> None: # pragma: no cover - protocol ) -> None: # pragma: no cover - protocol
... ...
@@ -468,23 +473,38 @@ class BackupServiceProtocol(Protocol):
class NodeRegistry: class NodeRegistry:
"""Thread-safe registry for tracking LoRA nodes in active workflows.""" """Thread-safe registry for tracking LoRA nodes across ComfyUI tabs.
Each connected ComfyUI browser tab (identified by its ``sid`` / ``clientId``)
registers its own set of workflow nodes. Queries merge all known tabs into
a single result so that the calling LM panel always sees *every* available
target node, regardless of which tab responded fastest.
"""
def __init__(self) -> None: def __init__(self) -> None:
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._nodes: Dict[str, dict] = {} # sid → {unique_id → node_info}
self._registry_updated = asyncio.Event() self._tab_nodes: Dict[str, Dict[str, dict]] = {}
self._ready = asyncio.Event()
self._waiting_clients: set[str] = set()
async def register_nodes(self, nodes: list[dict]) -> None: @property
async with self._lock: def pending_client_count(self) -> int:
self._nodes.clear() """Number of clients that have not yet responded in the current refresh cycle."""
for node in nodes: return len(self._waiting_clients)
# ------------------------------------------------------------------
# Helpers to build one node dict (extracted so it's reused for each tab)
# ------------------------------------------------------------------
@staticmethod
def _build_node_dict(node: dict) -> dict:
node_id = node["node_id"] node_id = node["node_id"]
graph_id = str(node["graph_id"]) graph_id = str(node["graph_id"])
unique_id = f"{graph_id}:{node_id}" unique_id = f"{graph_id}:{node_id}"
node_type = node.get("type", "") node_type = node.get("type", "")
type_id = NODE_TYPES.get(node_type, 0) type_id = NODE_TYPES.get(node_type, 0)
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
raw_capabilities = node.get("capabilities") raw_capabilities = node.get("capabilities")
capabilities: dict = {} capabilities: dict = {}
if isinstance(raw_capabilities, dict): if isinstance(raw_capabilities, dict):
@@ -519,7 +539,7 @@ class NodeRegistry:
if not isinstance(comfy_class, str) or not comfy_class: if not isinstance(comfy_class, str) or not comfy_class:
comfy_class = node_type if isinstance(node_type, str) else None comfy_class = node_type if isinstance(node_type, str) else None
self._nodes[unique_id] = { return {
"id": node_id, "id": node_id,
"graph_id": graph_id, "graph_id": graph_id,
"graph_name": node.get("graph_name"), "graph_name": node.get("graph_name"),
@@ -532,25 +552,75 @@ class NodeRegistry:
"capabilities": capabilities, "capabilities": capabilities,
"widget_names": widget_names, "widget_names": widget_names,
"mode": node.get("mode"), "mode": node.get("mode"),
"marker_role": node.get("marker_role"),
} }
logger.debug("Registered %s nodes in registry", len(nodes))
self._registry_updated.set()
async def get_registry(self) -> dict: # ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def register_nodes(self, sid: str, nodes: list[dict]) -> None:
"""Register/replace the node list for a single ComfyUI tab (identified by *sid*)."""
tab_nodes: dict[str, dict] = {}
for node in nodes:
nd = self._build_node_dict(node)
tab_nodes[nd["unique_id"]] = nd
async with self._lock: async with self._lock:
return { self._tab_nodes[sid] = tab_nodes
"nodes": dict(self._nodes), self._waiting_clients.discard(sid)
"node_count": len(self._nodes), if not self._waiting_clients:
} self._ready.set()
async def wait_for_update(self, timeout: float = 1.0) -> bool: logger.debug("Registered %s nodes from client %s", len(nodes), sid)
self._registry_updated.clear()
def prepare_for_refresh(self, active_sids: list[str]) -> None:
"""Set the list of client IDs we expect to hear from during the next refresh cycle."""
self._ready.clear()
self._waiting_clients = set(active_sids)
async def wait_for_all(self, timeout: float = 2.0) -> bool:
"""Block until every client in the current waiting set has responded
(or *timeout* seconds elapse). Returns ``True`` if all responded."""
if not self._waiting_clients:
return True
try: try:
await asyncio.wait_for(self._registry_updated.wait(), timeout=timeout) await asyncio.wait_for(self._ready.wait(), timeout=timeout)
return True return True
except asyncio.TimeoutError: except asyncio.TimeoutError:
return False return False
async def get_merged_registry(self, active_sids: set[str] | None = None) -> dict:
"""Return the union of all known tab nodes, pruning any tab that is no
longer connected."""
async with self._lock:
# Garbage-collect stale entries (disconnected tabs)
if active_sids is not None:
for sid in list(self._tab_nodes):
if sid not in active_sids:
del self._tab_nodes[sid]
merged: dict[str, dict] = {}
tab_info: dict[str, dict] = {}
for sid, nodes in self._tab_nodes.items():
tab_info[sid] = {
"node_count": len(nodes),
"graph_names": list(
{
n.get("graph_name")
for n in nodes.values()
if n.get("graph_name")
}
),
}
merged.update(nodes)
return {
"nodes": merged,
"node_count": len(merged),
"tab_count": len(self._tab_nodes),
"tabs": tab_info,
}
class HealthCheckHandler: class HealthCheckHandler:
async def health_check(self, request: web.Request) -> web.Response: async def health_check(self, request: web.Request) -> web.Response:
@@ -1328,6 +1398,9 @@ class SettingsHandler:
"folder_paths", "folder_paths",
"libraries", "libraries",
"active_library", "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 +1455,9 @@ class SettingsHandler:
value = self._settings.get(key) value = self._settings.get(key)
if value is not None: if value is not None:
response_data[key] = value 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) settings_file = getattr(self._settings, "settings_file", None)
if settings_file: if settings_file:
response_data["settings_file"] = settings_file response_data["settings_file"] = settings_file
@@ -1492,6 +1568,16 @@ class SettingsHandler:
if not os.path.isdir(folder_path): if not os.path.isdir(folder_path):
return "Please set a dedicated folder for example images." return "Please set a dedicated folder for example images."
if not self._is_dedicated_example_images_folder(folder_path): 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 "Please set a dedicated folder for example images."
return None return None
@@ -2975,10 +3061,21 @@ class NodeRegistryHandler:
try: try:
data = await request.json() data = await request.json()
nodes = data.get("nodes", []) nodes = data.get("nodes", [])
client_id = data.get("client_id")
if not isinstance(nodes, list): if not isinstance(nodes, list):
return web.json_response( return web.json_response(
{"success": False, "error": "nodes must be a list"}, status=400 {"success": False, "error": "nodes must be a list"}, status=400
) )
if not isinstance(client_id, str) or not client_id:
return web.json_response(
{
"success": False,
"error": "Missing client_id parameter",
},
status=400,
)
for index, node in enumerate(nodes): for index, node in enumerate(nodes):
if not isinstance(node, dict): if not isinstance(node, dict):
return web.json_response( return web.json_response(
@@ -3022,7 +3119,7 @@ class NodeRegistryHandler:
else: else:
node["graph_name"] = str(graph_name) node["graph_name"] = str(graph_name)
await self._node_registry.register_nodes(nodes) await self._node_registry.register_nodes(client_id, nodes)
return web.json_response( return web.json_response(
{ {
"success": True, "success": True,
@@ -3046,9 +3143,15 @@ class NodeRegistryHandler:
status=503, status=503,
) )
# Snapshot of currently-connected ComfyUI tabs
active_sids = list(self._prompt_server.instance.sockets.keys())
self._node_registry.prepare_for_refresh(active_sids)
try: try:
self._prompt_server.instance.send_sync("lora_registry_refresh", {}) self._prompt_server.instance.send_sync("lora_registry_refresh", {})
logger.debug("Sent registry refresh request to frontend") logger.debug(
"Sent registry refresh request (expecting %s clients)", len(active_sids)
)
except Exception as exc: except Exception as exc:
logger.error("Failed to send registry refresh message: %s", exc) logger.error("Failed to send registry refresh message: %s", exc)
return web.json_response( return web.json_response(
@@ -3060,19 +3163,31 @@ class NodeRegistryHandler:
status=500, status=500,
) )
registry_updated = await self._node_registry.wait_for_update(timeout=1.0) if not await self._node_registry.wait_for_all(timeout=2.0):
if not registry_updated: logger.warning(
logger.warning("Registry refresh timeout after 1 second") "Registry refresh timeout after 2s (%s/%s clients responded)",
len(active_sids) - self._node_registry.pending_client_count,
len(active_sids),
)
# Re-read current sockets after the wait: a tab may have connected
# while we were waiting, and we don't want to garbage-collect it.
current_sids = set(self._prompt_server.instance.sockets.keys())
registry_info = await self._node_registry.get_merged_registry(
active_sids=current_sids
)
if registry_info["node_count"] == 0:
logger.warning("No nodes registered after refresh")
return web.json_response( return web.json_response(
{ {
"success": False, "success": False,
"error": "Timeout Error", "error": "Empty Registry",
"message": "Registry refresh timeout - ComfyUI frontend may not be responsive", "message": "No workflow nodes found — ensure ComfyUI is open and the extension is loaded.",
}, },
status=408, status=408,
) )
registry_info = await self._node_registry.get_registry()
return web.json_response({"success": True, "data": registry_info}) return web.json_response({"success": True, "data": registry_info})
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to get registry: %s", exc, exc_info=True) logger.error("Failed to get registry: %s", exc, exc_info=True)
@@ -3085,13 +3200,17 @@ class NodeRegistryHandler:
try: try:
data = await request.json() data = await request.json()
widget_name = data.get("widget_name") widget_name = data.get("widget_name")
action = data.get("action")
value = data.get("value") value = data.get("value")
mode = data.get("mode", "replace") mode = data.get("mode", "replace")
node_ids = data.get("node_ids") node_ids = data.get("node_ids")
if not isinstance(widget_name, str) or not widget_name: if not action and (not isinstance(widget_name, str) or not widget_name):
return web.json_response( return web.json_response(
{"success": False, "error": "Missing widget_name parameter"}, {
"success": False,
"error": "Missing parameter: provide either 'action' or 'widget_name'",
},
status=400, status=400,
) )
@@ -3130,12 +3249,15 @@ class NodeRegistryHandler:
except (TypeError, ValueError): except (TypeError, ValueError):
parsed_node_id = node_identifier parsed_node_id = node_identifier
payload = { payload: dict = {
"id": parsed_node_id, "id": parsed_node_id,
"widget_name": widget_name,
"value": value, "value": value,
"mode": mode, "mode": mode,
} }
if action:
payload["action"] = action
if widget_name:
payload["widget_name"] = widget_name
if graph_identifier is not None: if graph_identifier is not None:
payload["graph_id"] = str(graph_identifier) payload["graph_id"] = str(graph_identifier)
@@ -3194,6 +3316,7 @@ class MiscHandlerSet:
doctor: DoctorHandler, doctor: DoctorHandler,
example_workflows: ExampleWorkflowsHandler, example_workflows: ExampleWorkflowsHandler,
base_model: BaseModelHandlerSet, base_model: BaseModelHandlerSet,
hf_handler: HfHandler | None = None,
) -> None: ) -> None:
self.health = health self.health = health
self.settings = settings self.settings = settings
@@ -3212,6 +3335,7 @@ class MiscHandlerSet:
self.doctor = doctor self.doctor = doctor
self.example_workflows = example_workflows self.example_workflows = example_workflows
self.base_model = base_model self.base_model = base_model
self.hf_handler = hf_handler
def to_route_mapping( def to_route_mapping(
self, self,
@@ -3257,6 +3381,9 @@ class MiscHandlerSet:
"get_supporters": self.supporters.get_supporters, "get_supporters": self.supporters.get_supporters,
"get_example_workflows": self.example_workflows.get_example_workflows, "get_example_workflows": self.example_workflows.get_example_workflows,
"get_example_workflow": self.example_workflows.get_example_workflow, "get_example_workflow": self.example_workflows.get_example_workflow,
# Hugging Face handlers
"get_hf_repo_files": self.hf_handler.get_hf_repo_files,
"download_hf_model": self.hf_handler.download_hf_model,
# Base model handlers # Base model handlers
"get_base_models": self.base_model.get_base_models, "get_base_models": self.base_model.get_base_models,
"refresh_base_models": self.base_model.refresh_base_models, "refresh_base_models": self.base_model.refresh_base_models,
+94 -18
View File
@@ -203,11 +203,17 @@ class ModelListingHandler:
result = await self._service.get_paginated_data(**params) result = await self._service.get_paginated_data(**params)
format_start = time.perf_counter() format_start = time.perf_counter()
formatted_raw = [
await self._service.format_response(entry)
for entry in result["items"]
]
# Filter out None entries returned for corrupted cache rows (issue #730).
# Note: "total" intentionally remains the pre-filter count to reflect
# the true number of models in the cache; corrupted entries are rare
# and adjusting total would cause pagination drift on every page.
formatted_items = [item for item in formatted_raw if item is not None]
formatted_result = { formatted_result = {
"items": [ "items": formatted_items,
await self._service.format_response(item)
for item in result["items"]
],
"total": result["total"], "total": result["total"],
"page": result["page"], "page": result["page"],
"page_size": result["page_size"], "page_size": result["page_size"],
@@ -233,14 +239,20 @@ class ModelListingHandler:
start_time = time.perf_counter() start_time = time.perf_counter()
try: try:
params = self._parse_common_params(request) params = self._parse_common_params(request)
# group_by_model is meaningless for excluded view; strip it
params.pop("group_by_model", None)
result = await self._service.get_excluded_paginated_data(**params) result = await self._service.get_excluded_paginated_data(**params)
format_start = time.perf_counter() format_start = time.perf_counter()
formatted_raw = [
await self._service.format_response(entry)
for entry in result["items"]
]
# Filter out None entries returned for corrupted cache rows (issue #730).
# "total" stays at the pre-filter count; see get_models for rationale.
formatted_items = [item for item in formatted_raw if item is not None]
formatted_result = { formatted_result = {
"items": [ "items": formatted_items,
await self._service.format_response(item)
for item in result["items"]
],
"total": result["total"], "total": result["total"],
"page": result["page"], "page": result["page"],
"page_size": result["page_size"], "page_size": result["page_size"],
@@ -366,6 +378,19 @@ class ModelListingHandler:
request.query.get("name_pattern_use_regex", "false").lower() == "true" request.query.get("name_pattern_use_regex", "false").lower() == "true"
) )
# Group-by-model flag: deduplicate versions sharing the same civitai modelId
group_by_model = (
request.query.get("group_by_model", "false").lower() == "true"
)
# View-local-versions filter: show all local versions of a specific model
civitai_model_id = request.query.get("civitai_model_id")
if civitai_model_id is not None:
try:
civitai_model_id = int(civitai_model_id)
except (TypeError, ValueError):
civitai_model_id = None
return { return {
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
@@ -389,6 +414,8 @@ class ModelListingHandler:
"name_pattern_include": name_pattern_include, "name_pattern_include": name_pattern_include,
"name_pattern_exclude": name_pattern_exclude, "name_pattern_exclude": name_pattern_exclude,
"name_pattern_use_regex": name_pattern_use_regex, "name_pattern_use_regex": name_pattern_use_regex,
"group_by_model": group_by_model,
"civitai_model_id": civitai_model_id,
**self._parse_specific_params(request), **self._parse_specific_params(request),
} }
@@ -516,8 +543,13 @@ class ModelManagementHandler:
if not success: if not success:
return web.json_response({"success": False, "error": error}) return web.json_response({"success": False, "error": error})
formatted_metadata = await self._service.format_response(model_data) formatted = await self._service.format_response(model_data)
return web.json_response({"success": True, "metadata": formatted_metadata}) if formatted is None:
return web.json_response(
{"success": False, "error": "Model entry is corrupted (missing file_path)"},
status=500,
)
return web.json_response({"success": True, "metadata": formatted})
except Exception as exc: except Exception as exc:
if is_expected_offline_error(str(exc)): if is_expected_offline_error(str(exc)):
return web.json_response( return web.json_response(
@@ -1074,10 +1106,12 @@ class ModelQueryHandler:
# Sort: originals first, copies last # Sort: originals first, copies last
sorted_models = self._sort_duplicate_group(filtered) sorted_models = self._sort_duplicate_group(filtered)
# Format response # Format response, filtering out corrupted entries (issue #730)
group = {"hash": sha256, "models": []} group = {"hash": sha256, "models": []}
for model in sorted_models: for model in sorted_models:
group["models"].append(await self._service.format_response(model)) formatted = await self._service.format_response(model)
if formatted is not None:
group["models"].append(formatted)
# Only include groups with 2+ models after filtering # Only include groups with 2+ models after filtering
if len(group["models"]) > 1: if len(group["models"]) > 1:
@@ -1194,9 +1228,9 @@ class ModelQueryHandler:
(m for m in cache.raw_data if m["file_path"] == path), None (m for m in cache.raw_data if m["file_path"] == path), None
) )
if model: if model:
group["models"].append( formatted = await self._service.format_response(model)
await self._service.format_response(model) if formatted is not None:
) group["models"].append(formatted)
hash_val = self._service.scanner.get_hash_by_filename(filename) hash_val = self._service.scanner.get_hash_by_filename(filename)
if hash_val: if hash_val:
main_path = self._service.get_path_by_hash(hash_val) main_path = self._service.get_path_by_hash(hash_val)
@@ -1206,9 +1240,9 @@ class ModelQueryHandler:
None, None,
) )
if main_model: if main_model:
group["models"].insert( formatted = await self._service.format_response(main_model)
0, await self._service.format_response(main_model) if formatted is not None:
) group["models"].insert(0, formatted)
if group["models"]: if group["models"]:
result.append(group) result.append(group)
return web.json_response( return web.json_response(
@@ -1272,6 +1306,14 @@ class ModelQueryHandler:
license_flags = (model_data or {}).get("license_flags") license_flags = (model_data or {}).get("license_flags")
if license_flags is not None: if license_flags is not None:
response_payload["license_flags"] = int(license_flags) 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(response_payload)
return web.json_response( return web.json_response(
{ {
@@ -1820,6 +1862,39 @@ class ModelDownloadHandler:
) )
return web.json_response({"success": False, "error": str(exc)}, status=500) 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: class ModelCivitaiHandler:
"""CivitAI integration endpoints.""" """CivitAI integration endpoints."""
@@ -2864,6 +2939,7 @@ class ModelHandlerSet:
"retry_all_failed_downloads": self.download.retry_all_failed_downloads, "retry_all_failed_downloads": self.download.retry_all_failed_downloads,
"complete_download_in_queue": self.download.complete_download_in_queue, "complete_download_in_queue": self.download.complete_download_in_queue,
"get_download_stats": self.download.get_download_stats, "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_versions": self.civitai.get_civitai_versions,
"get_civitai_model_by_version": self.civitai.get_civitai_model_by_version, "get_civitai_model_by_version": self.civitai.get_civitai_model_by_version,
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash, "get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
+39 -1
View File
@@ -32,6 +32,7 @@ from ...utils.civitai_utils import (
extract_civitai_image_id_from_cdn_url, extract_civitai_image_id_from_cdn_url,
rewrite_preview_url, rewrite_preview_url,
) )
from ...utils.constants import NSFW_LEVELS
from ...utils.exif_utils import ExifUtils from ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher from ...recipes.enrichment import RecipeEnricher
@@ -1120,6 +1121,13 @@ class RecipeManagementHandler:
if parsed_embedded.get("base_model") and not metadata.get("base_model"): if parsed_embedded.get("base_model") and not metadata.get("base_model"):
metadata["base_model"] = parsed_embedded["base_model"] metadata["base_model"] = parsed_embedded["base_model"]
# Extract preview_nsfw_level from the CivitAI API response
# (injected into civitai_meta_raw by _download_remote_media).
if isinstance(civitai_meta_raw, dict):
bl = civitai_meta_raw.get("browsingLevel")
if isinstance(bl, int) and bl > 0:
metadata["preview_nsfw_level"] = bl
civitai_client = self._civitai_client_getter() civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe( await RecipeEnricher.enrich_recipe(
recipe=metadata, recipe=metadata,
@@ -1515,8 +1523,31 @@ class RecipeManagementHandler:
# CivitAI API returns modelVersionIds at the root level of # CivitAI API returns modelVersionIds at the root level of
# the image response, NOT inside the meta object. # the image response, NOT inside the meta object.
mvids = image_info.get("modelVersionIds") mvids = image_info.get("modelVersionIds")
if mvids and isinstance(civitai_meta_raw, dict): if mvids:
if isinstance(civitai_meta_raw, dict):
civitai_meta_raw["modelVersionIds"] = mvids civitai_meta_raw["modelVersionIds"] = mvids
else:
# meta is null but modelVersionIds exists — create a
# minimal dict so downstream parsers can discover
# LoRAs and checkpoints from the API response.
civitai_meta_raw = {"modelVersionIds": mvids}
# Inject browsingLevel (canonical integer) so the recipe's
# preview_nsfw_level can be set, enabling proper NSFW blur
# of the preview image. Fall back to nsfwLevel (string)
# when browsingLevel is absent.
if isinstance(civitai_meta_raw, dict):
browsing_level = image_info.get("browsingLevel")
nsfw_level_str = image_info.get("nsfwLevel")
if isinstance(browsing_level, int) and browsing_level > 0:
civitai_meta_raw["browsingLevel"] = browsing_level
elif (
isinstance(nsfw_level_str, str)
and nsfw_level_str in NSFW_LEVELS
):
civitai_meta_raw["browsingLevel"] = NSFW_LEVELS[
nsfw_level_str
]
original_url = ( original_url = (
image_info.get("url") if civitai_image_id and image_info else None image_info.get("url") if civitai_image_id and image_info else None
@@ -1796,6 +1827,13 @@ class RecipeManagementHandler:
"source_path": image_url, "source_path": image_url,
} }
# Extract preview_nsfw_level from the CivitAI API response
# (injected into civitai_meta_raw by _download_remote_media).
if isinstance(civitai_meta_raw, dict):
bl = civitai_meta_raw.get("browsingLevel")
if isinstance(bl, int) and bl > 0:
metadata["preview_nsfw_level"] = bl
if civitai_parsed: if civitai_parsed:
civitai_loras = civitai_parsed.get("loras", []) civitai_loras = civitai_parsed.get("loras", [])
if civitai_loras and not metadata.get("loras"): if civitai_loras and not metadata.get("loras"):
+7
View File
@@ -94,6 +94,13 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"GET", "/api/lm/delete-model-version", "delete_model_version" "GET", "/api/lm/delete-model-version", "delete_model_version"
), ),
# Hugging Face model endpoints
RouteDefinition(
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
),
RouteDefinition(
"POST", "/api/lm/download-hf-model", "download_hf_model"
),
) )
+3
View File
@@ -39,6 +39,7 @@ from .handlers.misc_handlers import (
build_service_registry_adapter, build_service_registry_adapter,
) )
from .handlers.base_model_handlers import BaseModelHandlerSet from .handlers.base_model_handlers import BaseModelHandlerSet
from .handlers.hf_handlers import HfHandler
from .misc_route_registrar import MiscRouteRegistrar from .misc_route_registrar import MiscRouteRegistrar
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -136,6 +137,7 @@ class MiscRoutes:
doctor = DoctorHandler(settings_service=self._settings) doctor = DoctorHandler(settings_service=self._settings)
example_workflows = ExampleWorkflowsHandler() example_workflows = ExampleWorkflowsHandler()
base_model = BaseModelHandlerSet() base_model = BaseModelHandlerSet()
hf_handler = HfHandler()
return self._handler_set_factory( return self._handler_set_factory(
health=health, health=health,
@@ -155,6 +157,7 @@ class MiscRoutes:
doctor=doctor, doctor=doctor,
example_workflows=example_workflows, example_workflows=example_workflows,
base_model=base_model, base_model=base_model,
hf_handler=hf_handler,
) )
+3
View File
@@ -138,6 +138,9 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"GET", "/api/lm/downloads/queue/complete", "complete_download_in_queue" "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("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
RouteDefinition("GET", "/{prefix}", "handle_models_page"), RouteDefinition("GET", "/{prefix}", "handle_models_page"),
) )
+45 -16
View File
@@ -11,6 +11,8 @@ from ..config import config
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from ..services.server_i18n import server_i18n from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry 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 from ..utils.usage_stats import UsageStats
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -140,6 +142,21 @@ class StatsRoutes:
# Get usage statistics # Get usage statistics
usage_data = await self.usage_stats.get_stats() 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({ return web.json_response({
'success': True, 'success': True,
'data': { 'data': {
@@ -154,7 +171,8 @@ class StatsRoutes:
'total_generations': usage_data.get('total_executions', 0), 'total_generations': usage_data.get('total_executions', 0),
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})), '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_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: if unused_lora_percent > 50:
insights.append({ insights.append({
'type': 'warning', 'type': 'warning',
'title': 'High Number of Unused LoRAs', 'key': 'insights.unusedLoras.high',
'description': f'{unused_lora_percent:.1f}% of your LoRAs ({unused_loras}/{total_loras}) have never been used.', 'params': {
'suggestion': 'Consider organizing or archiving unused models to free up storage space.' 'percent': f'{unused_lora_percent:.1f}',
'count': str(unused_loras),
'total': str(total_loras)
}
}) })
if total_checkpoints > 0: if total_checkpoints > 0:
@@ -469,9 +490,12 @@ class StatsRoutes:
if unused_checkpoint_percent > 30: if unused_checkpoint_percent > 30:
insights.append({ insights.append({
'type': 'warning', 'type': 'warning',
'title': 'Unused Checkpoints Detected', 'key': 'insights.unusedCheckpoints.detected',
'description': f'{unused_checkpoint_percent:.1f}% of your checkpoints ({unused_checkpoints}/{total_checkpoints}) have never been used.', 'params': {
'suggestion': 'Review and consider removing checkpoints you no longer need.' 'percent': f'{unused_checkpoint_percent:.1f}',
'count': str(unused_checkpoints),
'total': str(total_checkpoints)
}
}) })
if total_embeddings > 0: if total_embeddings > 0:
@@ -479,9 +503,12 @@ class StatsRoutes:
if unused_embedding_percent > 50: if unused_embedding_percent > 50:
insights.append({ insights.append({
'type': 'warning', 'type': 'warning',
'title': 'High Number of Unused Embeddings', 'key': 'insights.unusedEmbeddings.high',
'description': f'{unused_embedding_percent:.1f}% of your embeddings ({unused_embeddings}/{total_embeddings}) have never been used.', 'params': {
'suggestion': 'Consider organizing or archiving unused embeddings to optimize your collection.' 'percent': f'{unused_embedding_percent:.1f}',
'count': str(unused_embeddings),
'total': str(total_embeddings)
}
}) })
# Storage insights # Storage insights
@@ -492,18 +519,20 @@ class StatsRoutes:
if total_size > 100 * 1024 * 1024 * 1024: # 100GB if total_size > 100 * 1024 * 1024 * 1024: # 100GB
insights.append({ insights.append({
'type': 'info', 'type': 'info',
'title': 'Large Collection Detected', 'key': 'insights.collection.large',
'description': f'Your model collection is using {self._format_size(total_size)} of storage.', 'params': {
'suggestion': 'Consider using external storage or cloud solutions for better organization.' 'size': self._format_size(total_size)
}
}) })
# Recent activity insight # Recent activity insight
if usage_data.get('total_executions', 0) > 100: if usage_data.get('total_executions', 0) > 100:
insights.append({ insights.append({
'type': 'success', 'type': 'success',
'title': 'Active User', 'key': 'insights.activity.active',
'description': f'You\'ve completed {usage_data["total_executions"]} generations so far!', 'params': {
'suggestion': 'Keep exploring and creating amazing content with your models.' 'count': str(usage_data['total_executions'])
}
}) })
return web.json_response({ return web.json_response({
+29 -4
View File
@@ -16,6 +16,27 @@ logger = logging.getLogger(__name__)
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError) NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
# User-managed directories that live inside the plugin folder (portable
# mode) and must survive a Git-based update. ``git clean -fd`` would
# otherwise delete them because they are untracked and, in released tags,
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
# regardless of whether it is ignored.
_PRESERVE_DIRS = ('settings.json', 'civitai', 'wildcards', 'backups', 'stats', 'logs', 'cache', 'model_cache')
def _clean_excludes() -> List[str]:
"""Build the ``-e`` arguments for ``git clean`` from :data:`_PRESERVE_DIRS`."""
excludes: List[str] = []
for name in _PRESERVE_DIRS:
excludes.append('-e')
excludes.append(name)
# For directories, also exclude nested matches explicitly
# (``-e dir`` alone matches the dir entry; ``-e dir/**`` guards
# contents under all git versions as defense-in-depth).
excludes.append('-e')
excludes.append(f'{name}/**')
return excludes
class UpdateRoutes: class UpdateRoutes:
"""Routes for handling plugin update checks""" """Routes for handling plugin update checks"""
@@ -365,6 +386,8 @@ class UpdateRoutes:
) )
return False, "" return False, ""
clean_excludes = _clean_excludes()
try: try:
# Open the Git repository # Open the Git repository
repo = git.Repo(plugin_root) repo = git.Repo(plugin_root)
@@ -376,8 +399,9 @@ class UpdateRoutes:
if nightly: if nightly:
# Reset to discard any local changes # Reset to discard any local changes
repo.git.reset('--hard') repo.git.reset('--hard')
# Clean untracked files # Clean untracked files, but preserve user-managed directories
repo.git.clean('-fd') # (wildcards, backups, stats, civitai, caches, settings.json).
repo.git.clean('-fd', *clean_excludes)
# Switch to main branch and pull latest # Switch to main branch and pull latest
main_branch = 'main' main_branch = 'main'
@@ -394,8 +418,9 @@ class UpdateRoutes:
else: else:
# Reset to discard any local changes # Reset to discard any local changes
repo.git.reset('--hard') repo.git.reset('--hard')
# Clean untracked files # Clean untracked files, but preserve user-managed directories
repo.git.clean('-fd') # (wildcards, backups, stats, civitai, caches, settings.json).
repo.git.clean('-fd', *clean_excludes)
# Get latest release tag # Get latest release tag
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True) tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
+72 -2
View File
@@ -84,6 +84,7 @@ class Aria2Downloader:
self._transfers: Dict[str, Aria2Transfer] = {} self._transfers: Dict[str, Aria2Transfer] = {}
self._poll_interval = 0.5 self._poll_interval = 0.5
self._state_store = Aria2TransferStateStore() self._state_store = Aria2TransferStateStore()
self._stderr_reader_task: Optional[asyncio.Task] = None
@property @property
def is_running(self) -> bool: def is_running(self) -> bool:
@@ -115,7 +116,7 @@ class Aria2Downloader:
try: try:
while True: while True:
status = await self.get_status(download_id) status = await self._get_status_with_retry(download_id)
if status is None: if status is None:
return False, "aria2 download not found" return False, "aria2 download not found"
@@ -136,6 +137,35 @@ class Aria2Downloader:
finally: finally:
self._transfers.pop(download_id, None) self._transfers.pop(download_id, None)
async def _get_status_with_retry(
self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0
) -> Optional[Dict[str, Any]]:
"""Call get_status with retry for transient RPC failures.
Only retries on :exc:`Aria2Error` (RPC-level failure). Returns
``None`` immediately when the download_id is not tracked (a missing
transfer is not a transient condition, so retrying is pointless).
A single failed RPC call should not immediately fail the download,
because aria2 may be temporarily busy (e.g. finalizing multiple
concurrent downloads) and a retry will often succeed.
"""
last_exc: Optional[Exception] = None
for attempt in range(max_retries):
try:
return await self.get_status(download_id)
except Aria2Error as exc:
last_exc = exc
if attempt < max_retries - 1:
logger.warning(
"aria2 get_status transient failure (attempt %d/%d) for %s: %s",
attempt + 1, max_retries, download_id, exc,
)
await asyncio.sleep(retry_delay)
raise Aria2Error(
f"Failed to query aria2 download status after {max_retries} attempts: {last_exc}"
) from last_exc
async def _schedule_download( async def _schedule_download(
self, self,
url: str, url: str,
@@ -312,6 +342,16 @@ class Aria2Downloader:
async def close(self) -> None: async def close(self) -> None:
"""Shut down the RPC process and session.""" """Shut down the RPC process and session."""
# Cancel the background stderr reader first so it stops reading
# from the pipe before the subprocess is terminated.
if self._stderr_reader_task is not None:
self._stderr_reader_task.cancel()
try:
await asyncio.wait_for(self._stderr_reader_task, timeout=2.0)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
self._stderr_reader_task = None
if self._rpc_session is not None: if self._rpc_session is not None:
await self._rpc_session.close() await self._rpc_session.close()
self._rpc_session = None self._rpc_session = None
@@ -331,6 +371,23 @@ class Aria2Downloader:
process.kill() process.kill()
await process.wait() await process.wait()
async def _drain_stderr(self) -> None:
"""Continuously drain aria2's stderr pipe so it never blocks.
When the 64 KB pipe buffer fills up, aria2's ``write()`` to stderr
blocks, which freezes the entire ``aria2c`` process including its
RPC handler. This background task reads lines from stderr as they
arrive and forwards them to Python's logger.
"""
try:
assert self._process is not None and self._process.stderr is not None
async for line in self._process.stderr:
text = line.decode("utf-8", errors="replace").rstrip()
if text:
logger.debug("aria2 stderr: %s", text)
except Exception:
pass
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None: async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
try: try:
result = callback(snapshot, snapshot) result = callback(snapshot, snapshot)
@@ -465,6 +522,17 @@ class Aria2Downloader:
await self._wait_until_ready() await self._wait_until_ready()
# Drain aria2's stderr in a background task so the pipe buffer
# never fills up. If the pipe blocks, aria2 itself freezes and
# cannot respond to RPC — this was the root cause of the
# "Failed to query aria2 download status" timeout bug.
# Must start AFTER _wait_until_ready to avoid a race where the
# drain task consumes aria2's early-exit error message before
# _wait_until_ready can read it.
self._stderr_reader_task = asyncio.create_task(
self._drain_stderr()
)
def _resolve_executable(self) -> str: def _resolve_executable(self) -> str:
settings = get_settings_manager() settings = get_settings_manager()
configured_path = (settings.get("aria2c_path") or "").strip() configured_path = (settings.get("aria2c_path") or "").strip()
@@ -584,7 +652,9 @@ class Aria2Downloader:
if self._rpc_session is None or self._rpc_session.closed: if self._rpc_session is None or self._rpc_session.closed:
async with self._rpc_session_lock: async with self._rpc_session_lock:
if self._rpc_session is None or self._rpc_session.closed: if self._rpc_session is None or self._rpc_session.closed:
timeout = aiohttp.ClientTimeout(total=30) timeout = aiohttp.ClientTimeout(
total=None, sock_connect=10, sock_read=60
)
self._rpc_session = aiohttp.ClientSession(timeout=timeout) self._rpc_session = aiohttp.ClientSession(timeout=timeout)
return self._rpc_session return self._rpc_session
+103 -4
View File
@@ -104,6 +104,100 @@ class BaseModelService(ABC):
fetch_duration = time.perf_counter() - t0 fetch_duration = time.perf_counter() - t0
initial_count = len(sorted_data) initial_count = len(sorted_data)
# Optionally filter by civitai model ID (shows all local versions of a specific model)
civitai_model_id = kwargs.get("civitai_model_id")
if civitai_model_id is not None:
sorted_data = [
item for item in sorted_data
if self._extract_model_id(item) == civitai_model_id
]
# VLM mode: always sort by version ID descending (newest version first),
# regardless of the current sort_by preference.
sorted_data.sort(
key=lambda x: self._extract_version_id(x) or 0,
reverse=True,
)
# Optionally group by civitai modelId, showing only the latest version per model
dedup_lost = 0
if kwargs.get("group_by_model") and civitai_model_id is None:
# Determine whether to further sub-group by base model
# When version_grouping is "same_base", versions with different
# base models are effectively different groups — the dedup key
# needs to include base_model so the version count and VLM flow
# stay consistent (card shows correct count for its base model).
ufs = self.settings.get("version_grouping", "same_base")
group_by_base = ufs == "same_base"
dedup_map = {} # (modelId [,base_model]) -> (item, version_id)
version_counter = {} # same-key -> count
standalone = []
for item in sorted_data:
mid = self._extract_model_id(item)
if mid is None:
standalone.append(item)
continue
key = (mid, item.get("base_model") or "") if group_by_base else mid
# Count all versions per key
version_counter[key] = version_counter.get(key, 0) + 1
vid = self._extract_version_id(item) or 0
if key not in dedup_map or vid > dedup_map[key][1]:
dedup_map[key] = (item, vid)
# Attach version_count to each surviving grouped item (shallow copy
# to avoid mutating cached dicts — the cache is shared across requests)
for key, (item, vid) in dedup_map.items():
item = dict(item)
item["version_count"] = version_counter[key]
dedup_map[key] = (item, vid)
dedup_lost = len(sorted_data) - (len(dedup_map) + len(standalone))
sorted_data = [entry[0] for entry in dedup_map.values()] + standalone
# Re-sort by version_count (grouped: after dedup; non-grouped: group internally, sort, expand)
if sort_params.key == "versions_count" and civitai_model_id is None:
reverse = sort_params.order == "desc"
if kwargs.get("group_by_model"):
# Grouped mode: items are already dedup'd with version_count attached
sorted_data.sort(
key=lambda x: (
x.get("version_count", 0),
(x.get("model_name") or x.get("file_name") or "").lower(),
x.get("file_path", "").lower(),
),
reverse=reverse,
)
else:
# Non-grouped mode: group internally, sort groups by count, expand
# Respect the version_grouping setting (same logic as grouped dedup)
ufs = self.settings.get("version_grouping", "same_base")
group_by_base = ufs == "same_base"
model_groups: Dict[Any, List[Dict]] = {}
ungrouped_standalone: List[Dict] = []
for item in sorted_data:
mid = self._extract_model_id(item)
if mid is None:
ungrouped_standalone.append(item)
continue
key = (mid, item.get("base_model") or "") if group_by_base else mid
model_groups.setdefault(key, []).append(item)
# Sort versions within each group by version id descending
for items in model_groups.values():
items.sort(
key=lambda x: self._extract_version_id(x) or 0,
reverse=True,
)
# Sort groups by version count
sorted_groups = sorted(
model_groups.values(),
key=lambda items: len(items),
reverse=reverse,
)
# Flatten: grouped items first, standalone items last
sorted_data = []
for items in sorted_groups:
sorted_data.extend(items)
sorted_data.extend(ungrouped_standalone)
t1 = time.perf_counter() t1 = time.perf_counter()
if hash_filters: if hash_filters:
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters) filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
@@ -172,7 +266,7 @@ class BaseModelService(ABC):
overall_duration = time.perf_counter() - overall_start overall_duration = time.perf_counter() - overall_start
logger.debug( logger.debug(
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). " "%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
"Counts: initial=%d, post_filter=%d, final=%d", "Counts: initial=%d, dedup=%d, post_filter=%d, final=%d",
self.__class__.__name__, self.__class__.__name__,
overall_duration, overall_duration,
fetch_duration, fetch_duration,
@@ -181,6 +275,7 @@ class BaseModelService(ABC):
pagination_duration, pagination_duration,
annotate_duration, annotate_duration,
initial_count, initial_count,
dedup_lost,
post_filter_count, post_filter_count,
final_count, final_count,
) )
@@ -495,7 +590,7 @@ class BaseModelService(ABC):
if not ordered_ids: if not ordered_ids:
return annotated return annotated
strategy_value = self.settings.get("update_flag_strategy") strategy_value = self.settings.get("version_grouping")
if isinstance(strategy_value, str) and strategy_value.strip(): if isinstance(strategy_value, str) and strategy_value.strip():
strategy = strategy_value.strip().lower() strategy = strategy_value.strip().lower()
else: else:
@@ -696,8 +791,12 @@ class BaseModelService(ABC):
} }
@abstractmethod @abstractmethod
async def format_response(self, model_data: Dict) -> Dict: async def format_response(self, model_data: Dict) -> Optional[Dict]:
"""Format model data for API response - must be implemented by subclasses""" """Format model data for API response - must be implemented by subclasses.
Subclasses should return None for corrupted entries so the handler
layer can filter them out. See issue #730.
"""
pass pass
# Common service methods that delegate to scanner # Common service methods that delegate to scanner
+4
View File
@@ -523,6 +523,10 @@ class BatchImportService:
if payload.get("checkpoint"): if payload.get("checkpoint"):
metadata["checkpoint"] = payload["checkpoint"] metadata["checkpoint"] = payload["checkpoint"]
nsfw = payload.get("preview_nsfw_level")
if isinstance(nsfw, int) and nsfw > 0:
metadata["preview_nsfw_level"] = nsfw
image_bytes = None image_bytes = None
image_base64 = payload.get("image_base64") image_base64 = payload.get("image_base64")
+26 -7
View File
@@ -1,6 +1,6 @@
import os import os
import logging import logging
from typing import Dict from typing import Dict, Optional
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags from .auto_tag_service import extract_auto_tags
@@ -21,20 +21,37 @@ class CheckpointService(BaseModelService):
""" """
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service) super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
async def format_response(self, checkpoint_data: Dict) -> Dict: async def format_response(self, checkpoint_data: Dict) -> Optional[Dict]:
"""Format Checkpoint data for API response""" """Format Checkpoint data for API response.
Returns None when the entry is missing critical fields (corrupted cache
row), so the handler layer can filter it out. See issue #730.
"""
# Guard against corrupted cache entries missing critical fields
file_path = checkpoint_data.get("file_path")
if not file_path or not isinstance(file_path, str):
logger.warning(
"Skipping corrupted checkpoint entry (missing file_path): %s",
checkpoint_data.get("file_name", "<unknown>"),
)
return None
# Get sub_type from cache entry (new canonical field) # Get sub_type from cache entry (new canonical field)
sub_type = checkpoint_data.get("sub_type", "checkpoint") sub_type = checkpoint_data.get("sub_type", "checkpoint")
file_name = checkpoint_data.get("file_name") or ""
model_name = checkpoint_data.get("model_name") or file_name
folder = checkpoint_data.get("folder") or ""
return { return {
"model_name": checkpoint_data["model_name"], "model_name": model_name,
"file_name": checkpoint_data["file_name"], "file_name": file_name,
"preview_url": config.get_preview_static_url(checkpoint_data.get("preview_url", "")), "preview_url": config.get_preview_static_url(checkpoint_data.get("preview_url", "")),
"preview_nsfw_level": checkpoint_data.get("preview_nsfw_level", 0), "preview_nsfw_level": checkpoint_data.get("preview_nsfw_level", 0),
"base_model": checkpoint_data.get("base_model", ""), "base_model": checkpoint_data.get("base_model", ""),
"folder": checkpoint_data["folder"], "folder": folder,
"sha256": checkpoint_data.get("sha256", ""), "sha256": checkpoint_data.get("sha256", ""),
"file_path": checkpoint_data["file_path"].replace(os.sep, "/"), "file_path": file_path.replace(os.sep, "/"),
"file_size": checkpoint_data.get("size", 0), "file_size": checkpoint_data.get("size", 0),
"modified": checkpoint_data.get("modified", ""), "modified": checkpoint_data.get("modified", ""),
"tags": checkpoint_data.get("tags", []), "tags": checkpoint_data.get("tags", []),
@@ -48,6 +65,8 @@ class CheckpointService(BaseModelService):
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)), "skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True), "civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data), "auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
"version_count": checkpoint_data.get("version_count"),
"hf_url": checkpoint_data.get("hf_url", ""),
} }
def find_duplicate_hashes(self) -> Dict: def find_duplicate_hashes(self) -> Dict:
+2 -2
View File
@@ -327,7 +327,7 @@ class CivArchiveClient:
if resolved: if resolved:
return resolved, None return resolved, None
logger.error("Error fetching version of CivArchive model by hash %s", model_hash[:10]) logger.debug("Error fetching version of CivArchive model by hash %s", model_hash[:10])
return None, "No version data found" return None, "No version data found"
except RateLimitError: except RateLimitError:
@@ -417,7 +417,7 @@ class CivArchiveClient:
if version_id is not None: if version_id is not None:
raw_id = version_data.get("id") raw_id = version_data.get("id")
if raw_id != version_id: if raw_id is not None and str(raw_id) != str(version_id):
logger.warning( logger.warning(
"Requested version %s doesn't match default version %s for model %s", "Requested version %s doesn't match default version %s for model %s",
version_id, version_id,
@@ -196,6 +196,7 @@ class CivitaiBaseModelService:
"ernie": "ERNI", "ernie": "ERNI",
"ernie turbo": "ETRB", "ernie turbo": "ETRB",
"nucleus": "NUCL", "nucleus": "NUCL",
"krea 2": "KR2",
"svd": "SVD", "svd": "SVD",
"ltxv": "LTXV", "ltxv": "LTXV",
"ltxv2": "LTV2", "ltxv2": "LTV2",
@@ -424,6 +425,7 @@ class CivitaiBaseModelService:
"Ernie", "Ernie",
"Ernie Turbo", "Ernie Turbo",
"Nucleus", "Nucleus",
"Krea 2",
], ],
} }
+1 -1
View File
@@ -56,7 +56,7 @@ class CivitaiClient:
self._MAX_CACHE_ENTRIES = 500 self._MAX_CACHE_ENTRIES = 500
def _build_image_info_url(self, image_id: str) -> str: def _build_image_info_url(self, image_id: str) -> str:
return f"{self.base_url}/images?imageId={image_id}&nsfw=X" return f"{self.base_url}/images?imageId={image_id}&nsfw=X&withMeta=true"
async def _make_request( async def _make_request(
self, self,
+88 -5
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 .downloader import get_downloader, DownloadProgress, DownloadStreamControl
from .aria2_downloader import Aria2Error, get_aria2_downloader from .aria2_downloader import Aria2Error, get_aria2_downloader
from .aria2_transfer_state import Aria2TransferStateStore from .aria2_transfer_state import Aria2TransferStateStore
from .download_queue_service import DownloadQueueService
# Download to temporary file first # Download to temporary file first
import tempfile import tempfile
@@ -360,6 +361,15 @@ class DownloadManager:
if self._active_downloads[task_id].get("transfer_backend") == "aria2": if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id) 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 # Use original download implementation
try: try:
# Check for cancellation before starting # Check for cancellation before starting
@@ -396,6 +406,22 @@ class DownloadManager:
if self._active_downloads[task_id].get("transfer_backend") == "aria2": if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id) 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 return result
except asyncio.CancelledError: except asyncio.CancelledError:
# Handle cancellation # Handle cancellation
@@ -404,6 +430,19 @@ class DownloadManager:
self._active_downloads[task_id]["bytes_per_second"] = 0.0 self._active_downloads[task_id]["bytes_per_second"] = 0.0
if self._active_downloads[task_id].get("transfer_backend") == "aria2": if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id) 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}") logger.info(f"Download cancelled for task {task_id}")
raise raise
except Exception as e: except Exception as e:
@@ -417,6 +456,22 @@ class DownloadManager:
self._active_downloads[task_id]["bytes_per_second"] = 0.0 self._active_downloads[task_id]["bytes_per_second"] = 0.0
if self._active_downloads[task_id].get("transfer_backend") == "aria2": if self._active_downloads[task_id].get("transfer_backend") == "aria2":
await self._persist_aria2_state(task_id) 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)} return {"success": False, "error": str(e)}
finally: finally:
# Schedule cleanup of download record after delay # Schedule cleanup of download record after delay
@@ -1233,10 +1288,24 @@ class DownloadManager:
"download_id": download_id, "download_id": download_id,
} }
# Check if this checkpoint should be treated as a diffusion model based on baseModel # Check if this checkpoint should be treated as a diffusion model
# Priority: (1) any file has type "UNet" or "Diffusion Model",
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
is_diffusion_model = False is_diffusion_model = False
if model_type == "checkpoint": if model_type == "checkpoint":
if base_model_value in DIFFUSION_MODEL_BASE_MODELS: # Check file types first (more direct signal from CivitAI)
version_files = version_info.get("files", [])
for f in version_files:
f_type = f.get("type", "")
if f_type in ("UNet", "Diffusion Model"):
is_diffusion_model = True
logger.info(
f"File type '{f_type}' detected, routing checkpoint to unet folder"
)
break
# Fallback to baseModel name check
if not is_diffusion_model and base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True is_diffusion_model = True
logger.info( logger.info(
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder" f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
@@ -1365,7 +1434,7 @@ class DownloadManager:
f f
for f in files for f in files
if f.get("primary") if f.get("primary")
and f.get("type") in ("Model", "Negative", "Diffusion Model") and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
), ),
None, None,
) )
@@ -1396,7 +1465,7 @@ class DownloadManager:
( (
f f
for f in files for f in files
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model") if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
), ),
None, None,
) )
@@ -1974,7 +2043,21 @@ class DownloadManager:
break break
last_error = result last_error = result
if os.path.exists(save_path): # For aria2: if the .aria2 control file is missing, aria2 considers
# the download complete. A transient RPC failure may have made us
# think the download failed even though the file is fully on disk.
# Keep the file so a retry can find it already complete.
if (
transfer_backend == "aria2"
and os.path.exists(save_path)
and not os.path.exists(f"{save_path}.aria2")
):
logger.warning(
"aria2 download reported failure but .aria2 file is absent "
"for %s — the file is likely complete. Preserving it for retry.",
save_path,
)
elif os.path.exists(save_path):
try: try:
os.remove(save_path) os.remove(save_path)
except Exception as e: except Exception as e:
+26 -7
View File
@@ -1,6 +1,6 @@
import os import os
import logging import logging
from typing import Dict from typing import Dict, Optional
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags from .auto_tag_service import extract_auto_tags
@@ -21,20 +21,37 @@ class EmbeddingService(BaseModelService):
""" """
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service) super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
async def format_response(self, embedding_data: Dict) -> Dict: async def format_response(self, embedding_data: Dict) -> Optional[Dict]:
"""Format Embedding data for API response""" """Format Embedding data for API response.
Returns None when the entry is missing critical fields (corrupted cache
row), so the handler layer can filter it out. See issue #730.
"""
# Guard against corrupted cache entries missing critical fields
file_path = embedding_data.get("file_path")
if not file_path or not isinstance(file_path, str):
logger.warning(
"Skipping corrupted embedding entry (missing file_path): %s",
embedding_data.get("file_name", "<unknown>"),
)
return None
# Get sub_type from cache entry (new canonical field) # Get sub_type from cache entry (new canonical field)
sub_type = embedding_data.get("sub_type", "embedding") sub_type = embedding_data.get("sub_type", "embedding")
file_name = embedding_data.get("file_name") or ""
model_name = embedding_data.get("model_name") or file_name
folder = embedding_data.get("folder") or ""
return { return {
"model_name": embedding_data["model_name"], "model_name": model_name,
"file_name": embedding_data["file_name"], "file_name": file_name,
"preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")), "preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")),
"preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0), "preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0),
"base_model": embedding_data.get("base_model", ""), "base_model": embedding_data.get("base_model", ""),
"folder": embedding_data["folder"], "folder": folder,
"sha256": embedding_data.get("sha256", ""), "sha256": embedding_data.get("sha256", ""),
"file_path": embedding_data["file_path"].replace(os.sep, "/"), "file_path": file_path.replace(os.sep, "/"),
"file_size": embedding_data.get("size", 0), "file_size": embedding_data.get("size", 0),
"modified": embedding_data.get("modified", ""), "modified": embedding_data.get("modified", ""),
"tags": embedding_data.get("tags", []), "tags": embedding_data.get("tags", []),
@@ -48,6 +65,8 @@ class EmbeddingService(BaseModelService):
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)), "skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True), "civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data), "auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
"version_count": embedding_data.get("version_count"),
"hf_url": embedding_data.get("hf_url", ""),
} }
def find_duplicate_hashes(self) -> Dict: def find_duplicate_hashes(self) -> Dict:
+26 -6
View File
@@ -24,23 +24,41 @@ class LoraService(BaseModelService):
""" """
super().__init__("lora", scanner, LoraMetadata, update_service=update_service) super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
async def format_response(self, lora_data: Dict) -> Dict: async def format_response(self, lora_data: Dict) -> Optional[Dict]:
"""Format LoRA data for API response""" """Format LoRA data for API response.
Returns None when the entry is missing critical fields (corrupted cache
row), so the handler layer can filter it out instead of crashing the
whole listing request. See issue #730.
"""
# Guard against corrupted cache entries missing critical fields
file_path = lora_data.get("file_path")
if not file_path or not isinstance(file_path, str):
logger.warning(
"Skipping corrupted LoRA entry (missing file_path): %s",
lora_data.get("file_name", "<unknown>"),
)
return None
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default # Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
# Normalize to lowercase for consistent API responses # Normalize to lowercase for consistent API responses
sub_type = resolve_sub_type(lora_data).lower() sub_type = resolve_sub_type(lora_data).lower()
file_name = lora_data.get("file_name") or ""
model_name = lora_data.get("model_name") or file_name
folder = lora_data.get("folder") or ""
return { return {
"model_name": lora_data["model_name"], "model_name": model_name,
"file_name": lora_data["file_name"], "file_name": file_name,
"preview_url": config.get_preview_static_url( "preview_url": config.get_preview_static_url(
lora_data.get("preview_url", "") lora_data.get("preview_url", "")
), ),
"preview_nsfw_level": lora_data.get("preview_nsfw_level", 0), "preview_nsfw_level": lora_data.get("preview_nsfw_level", 0),
"base_model": lora_data.get("base_model", ""), "base_model": lora_data.get("base_model", ""),
"folder": lora_data["folder"], "folder": folder,
"sha256": lora_data.get("sha256", ""), "sha256": lora_data.get("sha256", ""),
"file_path": lora_data["file_path"].replace(os.sep, "/"), "file_path": file_path.replace(os.sep, "/"),
"file_size": lora_data.get("size", 0), "file_size": lora_data.get("size", 0),
"modified": lora_data.get("modified", ""), "modified": lora_data.get("modified", ""),
"tags": lora_data.get("tags", []), "tags": lora_data.get("tags", []),
@@ -59,6 +77,8 @@ class LoraService(BaseModelService):
lora_data.get("civitai", {}), minimal=True lora_data.get("civitai", {}), minimal=True
), ),
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data), "auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
"version_count": lora_data.get("version_count"),
"hf_url": lora_data.get("hf_url", ""),
} }
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]: async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
+12 -1
View File
@@ -427,7 +427,18 @@ class MetadataSyncService:
metadata = await metadata_loader(metadata_path) metadata = await metadata_loader(metadata_path)
for key, value in updates.items(): 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) metadata[key].update(value)
else: else:
metadata[key] = value metadata[key] = value
+13
View File
@@ -18,6 +18,8 @@ SUPPORTED_SORT_MODES = [
('size', 'desc'), ('size', 'desc'),
('usage', 'asc'), ('usage', 'asc'),
('usage', 'desc'), ('usage', 'desc'),
('versions_count', 'asc'),
('versions_count', 'desc'),
] ]
# Is this in use? # Is this in use?
@@ -263,6 +265,17 @@ class ModelCache:
), ),
reverse=reverse reverse=reverse
) )
elif sort_key == 'versions_count':
# Pre-dedup sort: fall back to name sort.
# Actual re-sort by version_count happens in get_paginated_data after dedup.
result = natsorted(
data,
key=lambda x: (
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse
)
else: else:
# Fallback: no sort # Fallback: no sort
result = list(data) result = list(data)
+17 -9
View File
@@ -294,12 +294,14 @@ class ModelFilterSet:
for tag, state in tag_filters.items(): for tag, state in tag_filters.items():
if not tag: if not tag:
continue continue
# Normalize to lowercase for case-insensitive matching
normalized = tag.strip().lower()
if state == "exclude": if state == "exclude":
exclude_tags.add(tag) exclude_tags.add(normalized)
else: else:
include_tags.add(tag) include_tags.add(normalized)
else: 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: if include_tags:
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any" tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
@@ -318,13 +320,17 @@ class ModelFilterSet:
return True return True
# Otherwise, check if all non-special tags match # Otherwise, check if all non-special tags match
if non_special_tags: 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 return True
# Normal case: all tags must match # Normal case: all tags must match (case-insensitive)
return all(tag in (item_tags or []) for tag in non_special_tags) 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: else:
# OR logic (default): item must have ANY include tag # OR logic (default): item must have ANY include tag (case-insensitive)
return any(tag in include_tags for tag in (item_tags or [])) 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"))] items = [item for item in items if matches_include(item.get("tags"))]
@@ -333,7 +339,9 @@ class ModelFilterSet:
def matches_exclude(item_tags): def matches_exclude(item_tags):
if not item_tags and "__no_tags__" in exclude_tags: if not item_tags and "__no_tags__" in exclude_tags:
return True 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 = [ items = [
item for item in items if not matches_exclude(item.get("tags")) item for item in items if not matches_exclude(item.get("tags"))
+11 -1
View File
@@ -248,6 +248,7 @@ class ModelScanner:
'civitai': civitai_slim, 'civitai': civitai_slim,
'civitai_deleted': bool(get_value('civitai_deleted', False)), 'civitai_deleted': bool(get_value('civitai_deleted', False)),
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)), 'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
'hf_url': get_value('hf_url', '') or '',
} }
license_source: Dict[str, Any] = {} license_source: Dict[str, Any] = {}
@@ -476,11 +477,20 @@ class ModelScanner:
for tag in adjusted_item.get('tags') or []: for tag in adjusted_item.get('tags') or []:
tags_count[tag] = tags_count.get(tag, 0) + 1 tags_count[tag] = tags_count.get(tag, 0) + 1
# Validate cache entries and check health # Validate cache entries and check health.
# Always use the validated/repaired entries — even when there are no
# invalid entries, auto_repair may have filled in missing optional
# fields (model_name, file_name, folder) with safe defaults on a copied
# working_entry. Without this unconditional replacement the repaired
# copies are discarded and None values propagate to format_response.
# See issue #730.
valid_entries, invalid_entries = CacheEntryValidator.validate_batch( valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
adjusted_raw_data, auto_repair=True adjusted_raw_data, auto_repair=True
) )
# Always use the validated entries (repaired copies)
adjusted_raw_data = valid_entries
if invalid_entries: if invalid_entries:
monitor = CacheHealthMonitor() monitor = CacheHealthMonitor()
report = monitor.check_health(adjusted_raw_data, auto_repair=True) report = monitor.check_health(adjusted_raw_data, auto_repair=True)
+49 -2
View File
@@ -724,6 +724,16 @@ class ModelUpdateService:
"Refreshing update metadata for %d %s models", total_models, model_type "Refreshing update metadata for %d %s models", total_models, model_type
) )
# When filtering by folder, also collect the cross-folder version set
# so that versions already present in other folders are not reported
# as available updates. See issue #997.
all_local_versions: Optional[Dict[int, List[int]]] = None
if folder_path is not None:
all_local_versions = await self._collect_local_versions(
scanner,
target_model_ids=target_filter,
)
results: Dict[int, ModelUpdateRecord] = {} results: Dict[int, ModelUpdateRecord] = {}
prefetched: Dict[int, Mapping] = {} prefetched: Dict[int, Mapping] = {}
@@ -762,6 +772,12 @@ class ModelUpdateService:
for index, (model_id, version_ids) in enumerate( for index, (model_id, version_ids) in enumerate(
local_versions.items(), start=1 local_versions.items(), start=1
): ):
# Use cross-folder version IDs for is_in_library if available
all_vids: Sequence[int] = (
all_local_versions.get(model_id, [])
if all_local_versions is not None
else version_ids
)
record = await self._refresh_single_model( record = await self._refresh_single_model(
model_type, model_type,
model_id, model_id,
@@ -769,6 +785,7 @@ class ModelUpdateService:
metadata_provider, metadata_provider,
force_refresh=force_refresh, force_refresh=force_refresh,
prefetched_response=prefetched.get(model_id), prefetched_response=prefetched.get(model_id),
all_local_version_ids=all_vids,
) )
if scanner.is_cancelled(): if scanner.is_cancelled():
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user") logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
@@ -964,8 +981,16 @@ class ModelUpdateService:
*, *,
force_refresh: bool = False, force_refresh: bool = False,
prefetched_response: Optional[Mapping] = None, prefetched_response: Optional[Mapping] = None,
all_local_version_ids: Optional[Sequence[int]] = None,
) -> Optional[ModelUpdateRecord]: ) -> Optional[ModelUpdateRecord]:
normalized_local = self._normalize_sequence(local_versions) normalized_local = self._normalize_sequence(local_versions)
# When folder-filtering, this carries the cross-folder version set
# for is_in_library; otherwise it falls back to normalized_local.
normalized_all = (
self._normalize_sequence(all_local_version_ids)
if all_local_version_ids is not None
else normalized_local
)
now = time.time() now = time.time()
async with self._lock: async with self._lock:
existing = self._get_record(model_type, model_id) existing = self._get_record(model_type, model_id)
@@ -973,6 +998,7 @@ class ModelUpdateService:
record = self._merge_with_local_versions( record = self._merge_with_local_versions(
existing, existing,
normalized_local, normalized_local,
all_local_version_ids=normalized_all,
) )
self._upsert_record(record) self._upsert_record(record)
return record return record
@@ -1048,6 +1074,7 @@ class ModelUpdateService:
record = self._merge_with_local_versions( record = self._merge_with_local_versions(
existing, existing,
normalized_local, normalized_local,
all_local_version_ids=normalized_all,
) )
self._upsert_record(record) self._upsert_record(record)
return record return record
@@ -1059,6 +1086,7 @@ class ModelUpdateService:
model_type=model_type, model_type=model_type,
model_id=model_id, model_id=model_id,
last_checked_at=now, last_checked_at=now,
all_local_version_ids=normalized_all,
) )
record = replace(record, should_ignore_model=True) record = replace(record, should_ignore_model=True)
self._upsert_record(record) self._upsert_record(record)
@@ -1077,6 +1105,7 @@ class ModelUpdateService:
fetched_versions, fetched_versions,
existing, existing,
now, now,
all_local_version_ids=normalized_all,
) )
else: else:
record = self._merge_with_local_versions( record = self._merge_with_local_versions(
@@ -1085,6 +1114,7 @@ class ModelUpdateService:
model_type=model_type, model_type=model_type,
model_id=model_id, model_id=model_id,
last_checked_at=existing.last_checked_at if existing else None, last_checked_at=existing.last_checked_at if existing else None,
all_local_version_ids=normalized_all,
) )
self._upsert_record(record) self._upsert_record(record)
return record return record
@@ -1322,12 +1352,20 @@ class ModelUpdateService:
existing: Optional[ModelUpdateRecord], existing: Optional[ModelUpdateRecord],
normalized_local: Sequence[int], normalized_local: Sequence[int],
*, *,
all_local_version_ids: Optional[Sequence[int]] = None,
model_type: Optional[str] = None, model_type: Optional[str] = None,
model_id: Optional[int] = None, model_id: Optional[int] = None,
last_checked_at: Optional[float] = None, last_checked_at: Optional[float] = None,
version_info: Optional[Mapping] = None, version_info: Optional[Mapping] = None,
) -> ModelUpdateRecord: ) -> ModelUpdateRecord:
local_set = set(normalized_local) local_set = set(normalized_local)
# When folder-filtering, also consider versions in other folders
# as in-library so they are not reported as available updates.
effective_local_set: set[int] = (
local_set | set(all_local_version_ids)
if all_local_version_ids is not None
else local_set
)
versions: List[ModelVersionRecord] = [] versions: List[ModelVersionRecord] = []
ignore_map: Dict[int, bool] = {} ignore_map: Dict[int, bool] = {}
if existing: if existing:
@@ -1339,7 +1377,7 @@ class ModelUpdateService:
versions.append( versions.append(
replace( replace(
version, version,
is_in_library=version.version_id in local_set, is_in_library=version.version_id in effective_local_set,
) )
) )
elif model_type is None or model_id is None: elif model_type is None or model_id is None:
@@ -1386,8 +1424,17 @@ class ModelUpdateService:
remote_versions: Sequence[ModelVersionRecord], remote_versions: Sequence[ModelVersionRecord],
existing: Optional[ModelUpdateRecord], existing: Optional[ModelUpdateRecord],
timestamp: float, timestamp: float,
*,
all_local_version_ids: Optional[Sequence[int]] = None,
) -> ModelUpdateRecord: ) -> ModelUpdateRecord:
local_set = set(local_versions) local_set = set(local_versions)
# When folder-filtering, also consider versions in other folders
# as in-library so they are not reported as available updates.
effective_local_set: set[int] = (
local_set | set(all_local_version_ids)
if all_local_version_ids is not None
else local_set
)
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {} ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {} preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {} sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
@@ -1406,7 +1453,7 @@ class ModelUpdateService:
released_at=remote_version.released_at, released_at=remote_version.released_at,
size_bytes=remote_version.size_bytes, size_bytes=remote_version.size_bytes,
preview_url=remote_version.preview_url or preview_map.get(version_id), preview_url=remote_version.preview_url or preview_map.get(version_id),
is_in_library=version_id in local_set, is_in_library=version_id in effective_local_set,
should_ignore=ignore_map.get(version_id, remote_version.should_ignore), should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
sort_index=sort_map.get(version_id, index), sort_index=sort_map.get(version_id, index),
early_access_ends_at=remote_version.early_access_ends_at, early_access_ends_at=remote_version.early_access_ends_at,
+14 -9
View File
@@ -57,6 +57,7 @@ class PersistentModelCache:
"db_checked", "db_checked",
"last_checked_at", "last_checked_at",
"hash_status", "hash_status",
"hf_url",
) )
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:] _MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
_instances: Dict[str, "PersistentModelCache"] = {} _instances: Dict[str, "PersistentModelCache"] = {}
@@ -165,8 +166,8 @@ class PersistentModelCache:
item = { item = {
"file_path": file_path, "file_path": file_path,
"file_name": row["file_name"], "file_name": row["file_name"] or "",
"model_name": row["model_name"], "model_name": row["model_name"] or "",
"folder": row["folder"] or "", "folder": row["folder"] or "",
"size": row["size"] or 0, "size": row["size"] or 0,
"modified": row["modified"] or 0.0, "modified": row["modified"] or 0.0,
@@ -188,6 +189,7 @@ class PersistentModelCache:
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]), "skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
"license_flags": int(license_value), "license_flags": int(license_value),
"hash_status": row["hash_status"] or "completed", "hash_status": row["hash_status"] or "completed",
"hf_url": row["hf_url"] or "",
} }
raw_data.append(item) raw_data.append(item)
@@ -452,6 +454,7 @@ class PersistentModelCache:
db_checked INTEGER, db_checked INTEGER,
last_checked_at REAL, last_checked_at REAL,
hash_status TEXT, hash_status TEXT,
hf_url TEXT DEFAULT '',
PRIMARY KEY (model_type, file_path) PRIMARY KEY (model_type, file_path)
); );
@@ -500,6 +503,7 @@ class PersistentModelCache:
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57). # Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}", "license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
"hash_status": "TEXT DEFAULT 'completed'", "hash_status": "TEXT DEFAULT 'completed'",
"hf_url": "TEXT DEFAULT ''",
} }
for column, definition in required_columns.items(): for column, definition in required_columns.items():
@@ -548,19 +552,19 @@ class PersistentModelCache:
return ( return (
model_type, model_type,
item.get("file_path"), item.get("file_path"),
item.get("file_name"), item.get("file_name") or "",
item.get("model_name"), item.get("model_name") or "",
item.get("folder"), item.get("folder") or "",
int(item.get("size") or 0), int(item.get("size") or 0),
float(item.get("modified") or 0.0), float(item.get("modified") or 0.0),
(item.get("sha256") or "").lower() or None, (item.get("sha256") or "").lower() or None,
item.get("base_model"), item.get("base_model") or "",
item.get("preview_url"), item.get("preview_url") or "",
int(item.get("preview_nsfw_level") or 0), int(item.get("preview_nsfw_level") or 0),
1 if item.get("from_civitai", True) else 0, 1 if item.get("from_civitai", True) else 0,
1 if item.get("favorite") else 0, 1 if item.get("favorite") else 0,
item.get("notes"), item.get("notes") or "",
item.get("usage_tips"), item.get("usage_tips") or "",
metadata_source, metadata_source,
civitai.get("id"), civitai.get("id"),
civitai.get("modelId"), civitai.get("modelId"),
@@ -575,6 +579,7 @@ class PersistentModelCache:
1 if item.get("db_checked") else 0, 1 if item.get("db_checked") else 0,
float(item.get("last_checked_at") or 0.0), float(item.get("last_checked_at") or 0.0),
item.get("hash_status", "completed"), item.get("hash_status", "completed"),
item.get("hf_url") or "",
) )
def _insert_model_sql(self) -> str: def _insert_model_sql(self) -> str:
+95 -16
View File
@@ -146,11 +146,38 @@ class RecipeAnalysisService:
): ):
metadata = metadata["meta"] metadata = metadata["meta"]
# Include modelVersionIds from root level if available # Include modelVersionIds from root level if available.
# Civitai API returns modelVersionIds at root level, not in meta # CivitAI API returns modelVersionIds at root level, not in meta.
# When meta is null (None), create a minimal dict so downstream
# parsers can still discover LoRAs and checkpoints.
model_version_ids = image_info.get("modelVersionIds") model_version_ids = image_info.get("modelVersionIds")
if model_version_ids and isinstance(metadata, dict): if model_version_ids:
if isinstance(metadata, dict):
metadata["modelVersionIds"] = model_version_ids metadata["modelVersionIds"] = model_version_ids
else:
metadata = {"modelVersionIds": model_version_ids}
# Inject browsingLevel (canonical integer) so the recipe's
# preview_nsfw_level can be set, enabling proper NSFW blur
# of the preview image. Fall back to nsfwLevel (string)
# when browsingLevel is absent.
if isinstance(metadata, dict):
browsing_level = image_info.get("browsingLevel")
nsfw_level_str = image_info.get("nsfwLevel")
if isinstance(browsing_level, int) and browsing_level > 0:
metadata["browsingLevel"] = browsing_level
elif (
isinstance(nsfw_level_str, str)
and nsfw_level_str
in (
"PG", "PG13", "R", "X", "XXX", "Blocked",
)
):
from ...utils.constants import NSFW_LEVELS
metadata["browsingLevel"] = NSFW_LEVELS.get(
nsfw_level_str, 0
)
# Validate that metadata contains meaningful recipe fields # Validate that metadata contains meaningful recipe fields
# If not, treat as None to trigger EXIF extraction from downloaded image # If not, treat as None to trigger EXIF extraction from downloaded image
@@ -171,12 +198,19 @@ class RecipeAnalysisService:
temp_path = self._create_temp_path(suffix=extension) temp_path = self._create_temp_path(suffix=extension)
await self._download_image(url, temp_path) await self._download_image(url, temp_path)
if metadata is None and not is_video: # Always extract EXIF from the downloaded image for generation
metadata = await asyncio.to_thread( # params (prompt, negative prompt, sampler, steps, etc.).
# Previously this was gated on ``metadata is None``, but that
# skipped EXIF entirely when API metadata (modelVersionIds,
# browsingLevel) is present, losing all generation parameters.
exif_metadata = None
if not is_video:
exif_metadata = await asyncio.to_thread(
self._exif_utils.extract_image_metadata, temp_path self._exif_utils.extract_image_metadata, temp_path
) )
if not metadata and civitai_image_id and image_info: # Fallback: try the original (non-optimized) image for EXIF data
if not exif_metadata and civitai_image_id and image_info:
original_url = image_info.get("url") original_url = image_info.get("url")
if original_url: if original_url:
self._logger.debug( self._logger.debug(
@@ -187,15 +221,38 @@ class RecipeAnalysisService:
orig_temp_path = self._create_temp_path(suffix=".png") orig_temp_path = self._create_temp_path(suffix=".png")
try: try:
await self._download_image(original_url, orig_temp_path) await self._download_image(original_url, orig_temp_path)
metadata = await asyncio.to_thread( exif_metadata = await asyncio.to_thread(
self._exif_utils.extract_image_metadata, self._exif_utils.extract_image_metadata,
orig_temp_path, orig_temp_path,
) )
finally: finally:
self._safe_cleanup(orig_temp_path) self._safe_cleanup(orig_temp_path)
# Parse EXIF data (typically a string like parameters/prompt/workflow)
# and API metadata (dict with modelVersionIds, browsingLevel) separately,
# then merge: API loras/checkpoint override, EXIF gen_params fill in gaps.
# This mirrors the two-pass approach in _do_import_from_url.
exif_parsed_result = None
if isinstance(exif_metadata, str):
exif_parser = self._recipe_parser_factory.create_parser(exif_metadata)
if exif_parser:
exif_data = await exif_parser.parse_metadata(
exif_metadata, recipe_scanner=recipe_scanner,
)
if exif_data and not exif_data.get("error"):
exif_parsed_result = exif_data
# Merge API metadata (dict) with EXIF data (if dict) for the
# CivitaiApiMetadataParser. If EXIF data is a string it was
# parsed above — don't try to merge a string into a dict.
merged = {}
if isinstance(exif_metadata, dict):
merged.update(exif_metadata)
if isinstance(metadata, dict):
merged.update(metadata)
result = await self._parse_metadata( result = await self._parse_metadata(
metadata or {}, merged,
recipe_scanner=recipe_scanner, recipe_scanner=recipe_scanner,
image_path=temp_path, image_path=temp_path,
include_image_base64=True, include_image_base64=True,
@@ -203,13 +260,23 @@ class RecipeAnalysisService:
extension=extension, extension=extension,
) )
if civitai_image_id and image_info and not result.payload.get("error"): # Merge EXIF string-parsed gen_params into the API result.
mvid = image_info.get("modelVersionId") # API gen_params take priority (they come later via update).
if not mvid: if exif_parsed_result and not result.payload.get("error"):
mvids = image_info.get("modelVersionIds") exif_gp = exif_parsed_result.get("gen_params") or {}
if isinstance(mvids, list) and mvids: result_gp = result.payload.get("gen_params") or {}
mvid = mvids[0] merged_gp = {**exif_gp, **result_gp}
if merged_gp:
result.payload["gen_params"] = merged_gp
if civitai_image_id and image_info and not result.payload.get("error"):
# Use the metadata dict we built (may contain modelVersionIds
# and browsingLevel from the API root level). Do NOT pass
# image_info.get("meta") — it is null for images whose meta
# lives at the root level only. Also do NOT derive
# model_version_id from modelVersionIds[0] — that array mixes
# checkpoints, LoRAs, and other types without ordering
# guarantees; the parser already resolved them correctly.
recipe_for_enrich = { recipe_for_enrich = {
"gen_params": result.payload.get("gen_params", {}), "gen_params": result.payload.get("gen_params", {}),
"loras": result.payload.get("loras", []), "loras": result.payload.get("loras", []),
@@ -222,8 +289,10 @@ class RecipeAnalysisService:
recipe=recipe_for_enrich, recipe=recipe_for_enrich,
civitai_client=civitai_client, civitai_client=civitai_client,
request_params=None, request_params=None,
prefetched_civitai_meta_raw=image_info.get("meta"), prefetched_civitai_meta_raw=(
prefetched_model_version_id=mvid, metadata if isinstance(metadata, dict) else None
),
prefetched_model_version_id=None,
) )
result.payload["gen_params"] = recipe_for_enrich["gen_params"] result.payload["gen_params"] = recipe_for_enrich["gen_params"]
@@ -232,6 +301,12 @@ class RecipeAnalysisService:
if recipe_for_enrich.get("base_model"): if recipe_for_enrich.get("base_model"):
result.payload["base_model"] = recipe_for_enrich["base_model"] result.payload["base_model"] = recipe_for_enrich["base_model"]
# Extract browsingLevel from our constructed metadata for NSFW blur
if isinstance(metadata, dict):
bl = metadata.get("browsingLevel")
if isinstance(bl, int) and bl > 0:
result.payload["preview_nsfw_level"] = bl
return result return result
finally: finally:
if temp_path: if temp_path:
@@ -314,6 +389,10 @@ class RecipeAnalysisService:
"prompt_type", "prompt_type",
"positive", "positive",
"negative", "negative",
# modelVersionIds is injected at the root level by CivitAI's image
# API when meta is null. It carries the version IDs of ALL models
# (checkpoint + LoRAs) used to generate the image.
"modelVersionIds",
} }
return any(field in metadata for field in recipe_fields) return any(field in metadata for field in recipe_fields)
+46 -28
View File
@@ -98,13 +98,15 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"lora_syntax_format": "legacy", "lora_syntax_format": "legacy",
"model_card_footer_action": "replace_preview", "model_card_footer_action": "replace_preview",
"show_version_on_card": True, "show_version_on_card": True,
"update_flag_strategy": "same_base", "version_grouping": "same_base",
"auto_organize_exclusions": [], "auto_organize_exclusions": [],
"metadata_refresh_skip_paths": [], "metadata_refresh_skip_paths": [],
"skip_previously_downloaded_model_versions": False, "skip_previously_downloaded_model_versions": False,
"download_skip_base_models": [], "download_skip_base_models": [],
"backup_auto_enabled": True, "backup_auto_enabled": True,
"backup_retention_count": 5, "backup_retention_count": 5,
"use_new_license_icons": True,
"group_by_model": False,
} }
@@ -133,6 +135,9 @@ class SettingsManager:
self._template_path = ( self._template_path = (
Path(__file__).resolve().parents[2] / "settings.json.example" 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.settings = self._load_settings()
self._migrate_setting_keys() self._migrate_setting_keys()
self._ensure_default_settings() self._ensure_default_settings()
@@ -164,6 +169,12 @@ class SettingsManager:
self._original_disk_payload = copy.deepcopy(data) self._original_disk_payload = copy.deepcopy(data)
if self._matches_template_payload(data): if self._matches_template_payload(data):
self._preserve_disk_template = True 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 return data
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
logger.error("Failed to parse settings.json: %s", exc) logger.error("Failed to parse settings.json: %s", exc)
@@ -734,6 +745,7 @@ class SettingsManager:
"includeTriggerWords": "include_trigger_words", "includeTriggerWords": "include_trigger_words",
"compactMode": "compact_mode", "compactMode": "compact_mode",
"modelCardFooterAction": "model_card_footer_action", "modelCardFooterAction": "model_card_footer_action",
"update_flag_strategy": "version_grouping",
} }
updated = False updated = False
@@ -1556,7 +1568,7 @@ class SettingsManager:
previous_dir = os.path.dirname(previous_path) or target_dir previous_dir = os.path.dirname(previous_path) or target_dir
if os.path.abspath(previous_path) != os.path.abspath(target_path): if os.path.abspath(previous_path) != os.path.abspath(target_path):
self._copy_model_cache_directory(previous_dir, target_dir) self._migrate_settings_directory_content(previous_dir, target_dir)
logger.info("Switching settings file to: %s", target_path) logger.info("Switching settings file to: %s", target_path)
self._pending_portable_switch = {"other_path": other_path} self._pending_portable_switch = {"other_path": other_path}
@@ -1591,46 +1603,52 @@ class SettingsManager:
finally: finally:
self._pending_portable_switch = None self._pending_portable_switch = None
def _copy_model_cache_directory(self, source_dir: str, target_dir: str) -> None: def _migrate_settings_directory_content(
"""Copy model_cache artifacts when switching storage locations.""" self, source_dir: str, target_dir: str
) -> None:
"""Migrate settings directory subdirectories when switching storage locations.
Copies the canonical subdirectories (cache, backups, logs, stats, wildcards)
from the old settings directory to the new one. Legacy cache artifacts
(model_cache, recipe_cache, etc.) are migrated lazily by
``resolve_cache_path_with_migration`` on first access.
Args:
source_dir: The previous settings directory path.
target_dir: The new settings directory path.
"""
if not source_dir or not target_dir: if not source_dir or not target_dir:
return return
source_cache_dir = os.path.join(source_dir, "model_cache") def _copy_dir(name: str) -> None:
target_cache_dir = os.path.join(target_dir, "model_cache") source = os.path.join(source_dir, name)
if os.path.isdir(source_cache_dir) and os.path.abspath( target = os.path.join(target_dir, name)
source_cache_dir if os.path.isdir(source) and os.path.abspath(source) != os.path.abspath(
) != os.path.abspath(target_cache_dir): target
):
try: try:
shutil.copytree( shutil.copytree(
source_cache_dir, source,
target_cache_dir, target,
dirs_exist_ok=True, dirs_exist_ok=True,
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"), ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
) )
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
"Failed to copy model_cache directory from %s to %s: %s", "Failed to copy directory %s from %s to %s: %s",
source_cache_dir, name,
target_cache_dir, source,
target,
exc, exc,
) )
source_cache_file = os.path.join(source_dir, "model_cache.sqlite") # Managed subdirectories under settings_dir
target_cache_file = os.path.join(target_dir, "model_cache.sqlite") _copy_dir("cache")
if os.path.isfile(source_cache_file) and os.path.abspath( _copy_dir("backups")
source_cache_file _copy_dir("logs")
) != os.path.abspath(target_cache_file): _copy_dir("stats")
try: _copy_dir("wildcards")
shutil.copy2(source_cache_file, target_cache_file)
except Exception as exc:
logger.warning(
"Failed to copy model_cache.sqlite from %s to %s: %s",
source_cache_file,
target_cache_file,
exc,
)
def _get_user_config_directory(self) -> str: def _get_user_config_directory(self) -> str:
"""Return the user configuration directory, falling back to ~/.config.""" """Return the user configuration directory, falling back to ~/.config."""
+2 -2
View File
@@ -36,9 +36,9 @@ class TagUpdateService:
if isinstance(tag, str) and tag.strip(): if isinstance(tag, str) and tag.strip():
# Convert all tags to lowercase to avoid case sensitivity issues on Windows # Convert all tags to lowercase to avoid case sensitivity issues on Windows
normalized = tag.strip().lower() normalized = tag.strip().lower()
if normalized.lower() not in existing_lower: if normalized not in existing_lower:
existing_tags.append(normalized) existing_tags.append(normalized)
existing_lower.append(normalized.lower()) existing_lower.append(normalized)
tags_added.append(normalized) tags_added.append(normalized)
metadata["tags"] = existing_tags metadata["tags"] = existing_tags
+17
View File
@@ -47,6 +47,20 @@ SUPPORTED_MEDIA_EXTENSIONS = {
"videos": [".mp4", ".webm"], "videos": [".mp4", ".webm"],
} }
# Model weight file extensions recognised by scanners.
# This is the union of all scanner extensions (lora, checkpoint, embedding).
MODEL_FILE_EXTENSIONS = {
".safetensors",
".ckpt",
".pt",
".pt2",
".bin",
".pth",
".pkl",
".sft",
".gguf",
}
# Valid sub-types for each scanner type # Valid sub-types for each scanner type
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"] VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"] VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
@@ -147,6 +161,8 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
"Qwen", "Qwen",
"ZImageBase", "ZImageBase",
"ZImageTurbo", "ZImageTurbo",
# Krea 2 — loaded via UNETLoader in ComfyUI
"Krea 2",
] ]
) )
@@ -213,5 +229,6 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
"Ernie", "Ernie",
"Ernie Turbo", "Ernie Turbo",
"Nucleus", "Nucleus",
"Krea 2",
] ]
) )
+70
View File
@@ -12,6 +12,18 @@ from ..services.settings_manager import get_settings_manager
_HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}") _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__) logger = logging.getLogger(__name__)
@@ -180,6 +192,22 @@ def is_hash_folder(name: str) -> bool:
return bool(_HEX_PATTERN.fullmatch(name or "")) 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: def is_valid_example_images_root(folder_path: str) -> bool:
"""Check whether a folder looks like a dedicated example images root.""" """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: for item in items:
item_path = os.path.join(folder_path, item) 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): if item == ".download_progress.json" and os.path.isfile(item_path):
continue 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 os.path.isdir(item_path):
if is_hash_folder(item): if is_hash_folder(item):
continue continue
@@ -211,6 +246,41 @@ def is_valid_example_images_root(folder_path: str) -> bool:
return True 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: def _library_folder_has_only_hash_dirs(path: str) -> bool:
"""Return True when a library subfolder only contains hash folders or metadata files.""" """Return True when a library subfolder only contains hash folders or metadata files."""
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.1.3" version = "1.1.6"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",
-134
View File
@@ -1,134 +0,0 @@
{
"id": 1746460,
"name": "Mixplin Style [Illustrious]",
"type": "LORA",
"description": "description",
"username": "Ty_Lee",
"downloadCount": 4207,
"favoriteCount": 0,
"commentCount": 8,
"ratingCount": 0,
"rating": 0,
"is_nsfw": true,
"nsfw_level": 31,
"createdAt": "2025-07-06T01:51:42.859Z",
"updatedAt": "2025-10-10T23:15:26.714Z",
"deletedAt": null,
"tags": [
"art",
"style",
"artist style",
"styles",
"mixplin",
"artiststyle"
],
"creator_id": "Ty_Lee",
"creator_username": "Ty_Lee",
"creator_name": "Ty_Lee",
"creator_url": "/users/Ty_Lee",
"versions": [
{
"id": 2042594,
"name": "v2.0",
"href": "/models/1746460?modelVersionId=2042594"
},
{
"id": 1976567,
"name": "v1.0",
"href": "/models/1746460?modelVersionId=1976567"
}
],
"version": {
"id": 1976567,
"modelId": 1746460,
"name": "v1.0",
"baseModel": "Illustrious",
"baseModelType": "Standard",
"description": null,
"downloadCount": 437,
"ratingCount": 0,
"rating": 0,
"is_nsfw": true,
"nsfw_level": 31,
"createdAt": "2025-07-05T10:17:28.716Z",
"updatedAt": "2025-10-10T23:15:26.756Z",
"deletedAt": null,
"files": [
{
"id": 1874043,
"name": "mxpln-illustrious-ty_lee.safetensors",
"type": "Model",
"sizeKB": 223124.37109375,
"downloadUrl": "https://civitai.com/api/download/models/1976567",
"modelId": 1746460,
"modelName": "Mixplin Style [Illustrious]",
"modelVersionId": 1976567,
"is_nsfw": true,
"nsfw_level": 31,
"sha256": "e2b7a280d6539556f23f380b3f71e4e22bc4524445c4c96526e117c6005c6ad3",
"createdAt": "2025-07-05T10:17:28.716Z",
"updatedAt": "2025-10-10T23:15:26.766Z",
"is_primary": false,
"mirrors": [
{
"filename": "mxpln-illustrious-ty_lee.safetensors",
"url": "https://civitai.com/api/download/models/1976567",
"source": "civitai",
"model_id": 1746460,
"model_version_id": 1976567,
"deletedAt": null,
"is_gated": false,
"is_paid": false
}
]
}
],
"images": [
{
"id": 86403595,
"url": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
"nsfwLevel": 1,
"width": 1560,
"height": 2280,
"hash": "U7G8Zp0w02%IA6%N00-;D]-W~VNG0nMw-.IV",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null,
"image_url": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
"link": "https://genur.art/posts/86403595"
}
],
"trigger": [
"mxpln"
],
"allow_download": true,
"download_url": "/api/download/models/1976567",
"platform_url": "https://civitai.com/models/1746460?modelVersionId=1976567",
"civitai_model_id": 1746460,
"civitai_model_version_id": 1976567,
"href": "/models/1746460?modelVersionId=1976567",
"mirrors": [
{
"platform": "tensorart",
"href": "/tensorart/models/904473536033245448/versions/904473536033245448",
"platform_url": "https://tensor.art/models/904473536033245448",
"name": "Mixplin Style MXP",
"version_name": "Mixplin",
"id": "904473536033245448",
"version_id": "904473536033245448"
}
]
},
"platform": "civitai",
"platform_name": "CivitAI",
"meta": {
"title": "Mixplin Style [Illustrious] - v1.0 - CivitAI Archive",
"description": "Mixplin Style [Illustrious] v1.0 is a Illustrious LORA AI model created by Ty_Lee for generating images of art, style, artist style, styles, mixplin, artiststyle",
"image": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
"canonical": "https://civarchive.com/models/1746460?modelVersionId=1976567"
}
}
-38
View File
@@ -1,38 +0,0 @@
CREATE TABLE models (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
username TEXT,
data TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE TABLE model_versions (
id INTEGER PRIMARY KEY,
model_id INTEGER NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
base_model TEXT NOT NULL,
published_at INTEGER,
data TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE INDEX model_versions_model_id_idx ON model_versions (model_id);
CREATE TABLE model_files (
id INTEGER PRIMARY KEY,
model_id INTEGER NOT NULL,
version_id INTEGER NOT NULL,
type TEXT NOT NULL,
sha256 TEXT,
data TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE INDEX model_files_model_id_idx ON model_files (model_id);
CREATE INDEX model_files_version_id_idx ON model_files (version_id);
CREATE TABLE archived_model_files (
file_id INTEGER PRIMARY KEY,
model_id INTEGER NOT NULL,
version_id INTEGER NOT NULL
) STRICT;
-110
View File
@@ -1,110 +0,0 @@
{
"id": 1231067,
"name": "Vivid Impressions Storybook Style",
"description": "<h3 id=\"if-you'd-like-to-support-me-feel-free-to-visit-my-ko-fi-page.-please-share-your-images-using-the-&quot;+add-post&quot;-button-below.-it-supports-the-creators.-thanks!-nnfwkvfly\">If you'd like to support me, feel free to visit my <a target=\"_blank\" rel=\"ugc\" href=\"https://ko-fi.com/pixelpawsai\">Ko-Fi</a> page. ❤️<br /><br />Please share your images using the \"<span style=\"color:rgb(250, 82, 82)\">+add post</span>\" button below. It supports the creators. Thanks! 💕</h3><h3 id=\"if-you-like-my-lora-please-like-comment-or-donate-some-buzz.-much-appreciated!-vyeqok3go\">If you like my LoRA, please<span style=\"color:rgb(230, 73, 128)\"> </span><span style=\"color:rgb(250, 82, 82)\">like</span>, <span style=\"color:rgb(250, 82, 82)\">comment</span>, or <span style=\"color:#fa5252\">donate some Buzz</span>. Much appreciated! ❤️</h3><h3 id=\"-lo912t8rj\"></h3><h3 id=\"trigger-word:-ppstorybook-wlggllim2\"><strong><span style=\"color:rgb(253, 126, 20)\">Trigger word: </span></strong>ppstorybook</h3><h3 id=\"strength:-0.8-experiment-as-you-like-luvhks6za\"><strong><span style=\"color:rgb(253, 126, 20)\">Strength: </span></strong>0.8, experiment as you like</h3>",
"allowNoCredit": true,
"allowCommercialUse": [
"Image",
"RentCivit",
"Rent",
"Sell"
],
"allowDerivatives": true,
"allowDifferentLicense": true,
"type": "LORA",
"minor": false,
"sfwOnly": false,
"poi": false,
"nsfw": false,
"nsfwLevel": 1,
"availability": "Public",
"cosmetic": null,
"supportsGeneration": true,
"stats": {
"downloadCount": 2183,
"favoriteCount": 0,
"thumbsUpCount": 416,
"thumbsDownCount": 0,
"commentCount": 12,
"ratingCount": 0,
"rating": 0,
"tippedAmountCount": 360
},
"creator": {
"username": "PixelPawsAI",
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/f3a1aa7c-0159-4dd8-884a-1e7ceb350f96/width=96/PixelPawsAI.jpeg"
},
"tags": [
"style",
"illustration",
"storybook"
],
"modelVersions": [
{
"id": 1387174,
"index": 0,
"name": "v1.0",
"baseModel": "Flux.1 D",
"baseModelType": "Standard",
"createdAt": "2025-02-08T11:15:47.197Z",
"publishedAt": "2025-02-08T11:29:04.487Z",
"status": "Published",
"availability": "Public",
"nsfwLevel": 1,
"trainedWords": [
"ppstorybook"
],
"covered": true,
"stats": {
"downloadCount": 2183,
"ratingCount": 0,
"rating": 0,
"thumbsUpCount": 416,
"thumbsDownCount": 0
},
"files": [
{
"id": 1289799,
"sizeKB": 18829.1484375,
"name": "pp-storybook_rank2_bf16.safetensors",
"type": "Model",
"pickleScanResult": "Success",
"pickleScanMessage": "No Pickle imports",
"virusScanResult": "Success",
"virusScanMessage": null,
"scannedAt": "2025-02-08T11:21:04.247Z",
"metadata": {
"format": "SafeTensor"
},
"hashes": {
"AutoV1": "F414C813",
"AutoV2": "9753338AB6",
"SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668",
"CRC32": "A65AE7B3",
"BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591",
"AutoV3": "34A22376739D"
},
"downloadUrl": "https://civitai.com/api/download/models/1387174",
"primary": true
}
],
"images": [
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/original=true/56547310.jpeg",
"nsfwLevel": 1,
"width": 832,
"height": 1216,
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
}
],
"downloadUrl": "https://civitai.com/api/download/models/1387174"
}
]
}
-100
View File
@@ -1,100 +0,0 @@
{
"id": 1387174,
"modelId": 1231067,
"name": "v1.0",
"createdAt": "2025-02-08T11:15:47.197Z",
"updatedAt": "2025-02-08T11:29:04.526Z",
"status": "Published",
"publishedAt": "2025-02-08T11:29:04.487Z",
"trainedWords": [
"ppstorybook"
],
"trainingStatus": null,
"trainingDetails": null,
"baseModel": "Flux.1 D",
"baseModelType": null,
"earlyAccessEndsAt": null,
"earlyAccessConfig": null,
"description": null,
"uploadType": "Created",
"usageControl": "Download",
"air": "urn:air:flux1:lora:civitai:1231067@1387174",
"stats": {
"downloadCount": 1436,
"ratingCount": 0,
"rating": 0,
"thumbsUpCount": 316
},
"model": {
"name": "Vivid Impressions Storybook Style",
"type": "LORA",
"nsfw": false,
"poi": false
},
"files": [
{
"id": 1289799,
"sizeKB": 18829.1484375,
"name": "pp-storybook_rank2_bf16.safetensors",
"type": "Model",
"pickleScanResult": "Success",
"pickleScanMessage": "No Pickle imports",
"virusScanResult": "Success",
"virusScanMessage": null,
"scannedAt": "2025-02-08T11:21:04.247Z",
"metadata": {
"format": "SafeTensor",
"size": null,
"fp": null
},
"hashes": {
"AutoV1": "F414C813",
"AutoV2": "9753338AB6",
"SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668",
"CRC32": "A65AE7B3",
"BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591",
"AutoV3": "34A22376739D"
},
"primary": true,
"downloadUrl": "https://civitai.com/api/download/models/1387174"
}
],
"images": [
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/width=832/56547310.jpeg",
"nsfwLevel": 1,
"width": 832,
"height": 1216,
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
"type": "image",
"metadata": {
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
"size": 1361590,
"width": 832,
"height": 1216
},
"meta": {
"Size": "832x1216",
"seed": 1116375220995209,
"Model": "flux_dev_fp8",
"steps": 23,
"hashes": {
"model": ""
},
"prompt": "ppstorybook,A dreamy bunny hopping across a rainbow bridge, with fluffy clouds surrounding it and tiny birds flying alongside, rendered in a magical, soft-focus style with pastel hues and glowing accents.",
"Version": "ComfyUI",
"sampler": "DPM++ 2M",
"cfgScale": 3.5,
"clipSkip": 1,
"resources": [],
"Model hash": ""
},
"availability": "Public",
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
}
],
"downloadUrl": "https://civitai.com/api/download/models/1387174"
}
-153
View File
@@ -1,153 +0,0 @@
{
"resource-stack": {
"class_type": "CheckpointLoaderSimple",
"inputs": { "ckpt_name": "urn:air:sdxl:checkpoint:civitai:827184@1410435" }
},
"resource-stack-1": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "urn:air:sdxl:lora:civitai:1107767@1253442",
"strength_model": 1,
"strength_clip": 1,
"model": ["resource-stack", 0],
"clip": ["resource-stack", 1]
}
},
"resource-stack-2": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "urn:air:sdxl:lora:civitai:1342708@1516344",
"strength_model": 1,
"strength_clip": 1,
"model": ["resource-stack-1", 0],
"clip": ["resource-stack-1", 1]
}
},
"resource-stack-3": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "urn:air:sdxl:lora:civitai:122359@135867",
"strength_model": 1.55,
"strength_clip": 1,
"model": ["resource-stack-2", 0],
"clip": ["resource-stack-2", 1]
}
},
"6": {
"class_type": "smZ CLIPTextEncode",
"inputs": {
"text": "masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking",
"parser": "comfy",
"text_g": "",
"text_l": "",
"ascore": 2.5,
"width": 0,
"height": 0,
"crop_w": 0,
"crop_h": 0,
"target_width": 0,
"target_height": 0,
"smZ_steps": 1,
"mean_normalization": true,
"multi_conditioning": true,
"use_old_emphasis_implementation": false,
"with_SDXL": false,
"clip": ["resource-stack-3", 1]
},
"_meta": { "title": "Positive" }
},
"7": {
"class_type": "smZ CLIPTextEncode",
"inputs": {
"text": "bad quality,worst quality,worst detail,sketch,censor",
"parser": "comfy",
"text_g": "",
"text_l": "",
"ascore": 2.5,
"width": 0,
"height": 0,
"crop_w": 0,
"crop_h": 0,
"target_width": 0,
"target_height": 0,
"smZ_steps": 1,
"mean_normalization": true,
"multi_conditioning": true,
"use_old_emphasis_implementation": false,
"with_SDXL": false,
"clip": ["resource-stack-3", 1]
},
"_meta": { "title": "Negative" }
},
"20": {
"class_type": "UpscaleModelLoader",
"inputs": { "model_name": "urn:air:other:upscaler:civitai:147759@164821" },
"_meta": { "title": "Load Upscale Model" }
},
"17": {
"class_type": "LoadImage",
"inputs": {
"image": "https://orchestration.civitai.com/v2/consumer/blobs/5KZ6358TW8CNEGPZKD08NVDB30",
"upload": "image"
},
"_meta": { "title": "Image Load" }
},
"19": {
"class_type": "ImageUpscaleWithModel",
"inputs": { "upscale_model": ["20", 0], "image": ["17", 0] },
"_meta": { "title": "Upscale Image (using Model)" }
},
"23": {
"class_type": "ImageScale",
"inputs": {
"upscale_method": "nearest-exact",
"crop": "disabled",
"width": 1280,
"height": 1856,
"image": ["19", 0]
},
"_meta": { "title": "Upscale Image" }
},
"21": {
"class_type": "VAEEncode",
"inputs": { "pixels": ["23", 0], "vae": ["resource-stack", 2] },
"_meta": { "title": "VAE Encode" }
},
"11": {
"class_type": "KSampler",
"inputs": {
"sampler_name": "euler_ancestral",
"scheduler": "normal",
"seed": 2088370631,
"steps": 47,
"cfg": 6.5,
"denoise": 0.3,
"model": ["resource-stack-3", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["21", 0]
},
"_meta": { "title": "KSampler" }
},
"13": {
"class_type": "VAEDecode",
"inputs": { "samples": ["11", 0], "vae": ["resource-stack", 2] },
"_meta": { "title": "VAE Decode" }
},
"12": {
"class_type": "SaveImage",
"inputs": { "filename_prefix": "ComfyUI", "images": ["13", 0] },
"_meta": { "title": "Save Image" }
},
"extra": {
"airs": [
"urn:air:other:upscaler:civitai:147759@164821",
"urn:air:sdxl:checkpoint:civitai:827184@1410435",
"urn:air:sdxl:lora:civitai:1107767@1253442",
"urn:air:sdxl:lora:civitai:1342708@1516344",
"urn:air:sdxl:lora:civitai:122359@135867"
]
},
"extraMetadata": "{\u0022prompt\u0022:\u0022masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking\u0022,\u0022negativePrompt\u0022:\u0022bad quality,worst quality,worst detail,sketch,censor\u0022,\u0022steps\u0022:47,\u0022cfgScale\u0022:6.5,\u0022sampler\u0022:\u0022euler_ancestral\u0022,\u0022workflowId\u0022:\u0022img2img-hires\u0022,\u0022resources\u0022:[{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1253442,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1516344,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:135867,\u0022strength\u0022:1.55}],\u0022remixOfId\u0022:32140259}"
}
@@ -1,18 +0,0 @@
a dynamic and dramatic digital artwork featuring a stylized anthropomorphic white tiger with striking yellow eyes. The tiger is depicted in a powerful stance, wielding a katana with one hand raised above its head. Its fur is detailed with black stripes, and its mane flows wildly, blending with the stormy background. The scene is set amidst swirling dark clouds and flashes of lightning, enhancing the sense of movement and energy. The composition is vertical, with the tiger positioned centrally, creating a sense of depth and intensity. The color palette is dominated by shades of blue, gray, and white, with bright highlights from the lightning. The overall style is reminiscent of fantasy or manga art, with a focus on dynamic action and dramatic lighting.
Negative prompt:
Steps: 30, Sampler: Undefined, CFG scale: 3.5, Seed: 90300501, Size: 832x1216, Clip skip: 2, Created Date: 2025-03-05T13:51:18.1770234Z, Civitai resources: [{"type":"checkpoint","modelVersionId":691639,"modelName":"FLUX","modelVersionName":"Dev"},{"type":"lora","weight":0.4,"modelVersionId":1202162,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Gothic Lines"},{"type":"lora","weight":0.8,"modelVersionId":1470588,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Retro"},{"type":"lora","weight":0.75,"modelVersionId":746484,"modelName":"Elden Ring - Yoshitaka Amano","modelVersionName":"V1"},{"type":"lora","weight":0.2,"modelVersionId":914935,"modelName":"Ink-style","modelVersionName":"ink-dynamic"},{"type":"lora","weight":0.2,"modelVersionId":1189379,"modelName":"Painterly Fantasy by ChronoKnight - [FLUX \u0026 IL]","modelVersionName":"FLUX"},{"type":"lora","weight":0.2,"modelVersionId":757030,"modelName":"Mezzotint Artstyle for Flux - by Ethanar","modelVersionName":"V1"}], Civitai metadata: {}
masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject,
dynamic angle, dutch angle, from below, epic half body portrait, gritty, wabi sabi, looking at viewer, woman is a geisha, parted lips,
holographic skin, holofoil glitter, faint, glowing, ethereal, neon hair, glowing hair, otherworldly glow, she is dangerous
<lora:ck-shadow-circuit-IL:0.78>, <lora:ck-nc-cyberpunk-IL-000011:0.4>, <lora:ck-neon-retrowave-IL:0.2>, <lora:ck-yoneyama-mai-IL-000014:0.4>
Negative prompt: score_6, score_5, score_4, bad quality, worst quality, worst detail, sketch, censorship, furry, window, headphones,
Steps: 30, Sampler: Euler a, Schedule type: Simple, CFG scale: 7, Seed: 1405717592, Size: 832x1216, Model hash: 1ad6ca7f70, Model: waiNSFWIllustrious_v100, Denoising strength: 0.35, Hires CFG Scale: 5, Hires upscale: 1.3, Hires steps: 20, Hires upscaler: 4x-AnimeSharp, Lora hashes: "ck-shadow-circuit-IL: 88e247aa8c3d, ck-nc-cyberpunk-IL-000011: 935e6755554c, ck-neon-retrowave-IL: edafb9df7da1, ck-yoneyama-mai-IL-000014: 1b9305692a2e", Version: f2.0.1v1.10.1-1.10.1, Diffusion in Low Bits: Automatic (fp16 LoRA)
Masterpiece, best quality, high quality, newest, highres, 8K, HDR, absurdres, 1girl, solo, futuristic warrior, sleek exosuit with glowing energy cores, long braided hair flowing behind, gripping a high-tech bow with an energy arrow drawn, standing on a floating platform overlooking a massive space station, planets and nebulae in the distance, soft glow from distant stars, cinematic depth, foreshortening, dynamic pose, dramatic sci-fi lighting.
Negative prompt: worst quality, normal quality, anatomical nonsense, bad anatomy,interlocked fingers, extra fingers,watermark,simple background, loli,
Steps: 20, Sampler: euler_ancestral_karras, CFG scale: 8.0, Seed: 691121152183439, Model: il\waiNSFWIllustrious_v110.safetensors, Model hash: c3688ee04c, Lora_0 Model name: iLLMythAn1m3Style.safetensors, Lora_0 Model hash: ba7a040786, Lora_0 Strength model: 1.0, Lora_0 Strength clip: 1.0, Hashes: {"model": "c3688ee04c", "lora:iLLMythAn1m3Style": "ba7a040786"}
Immerse yourself in the enchanting journey, where harmonious transmutation of Bauhaus art unites photographic precision and contemporary illustration, capturing an enthralling blend between vivid abstract nature and urban landscapes. Let your eyes be captivated by a kaleidoscope of rich, deep reds and yellows, entwined with intriguing shades that beckon a somber atmosphere. As your spirit ventures along this haunting path, witness the mysterious, high-angle perspective dominated by scattered clouds granting you a mesmerizing glimpse into the ever-transforming realm of metamorphosing environments. ,<lora:flux/fav/ck-charcoal-drawing-000014.safetensors:1.0:1.0>
Negative prompt:
Steps: 20, Sampler: Euler, CFG scale: 3.5, Seed: 885491426361006, Size: 832x1216, Model hash: 4610115bb0, Model: flux_dev, Hashes: {"LORA:flux/fav/ck-charcoal-drawing-000014.safetensors": "34d36c17c1", "model": "4610115bb0"}, Version: ComfyUI
-3
View File
@@ -1,3 +0,0 @@
In this ethereal masterpiece, metallic sculptures juxtapose effortlessly against a subtle backdrop of misty neutral hues. Exquisite curvatures and geometric shapes converge harmoniously, creating an illuminating realm of polished metallic surfaces. Shimmering copper, gleaming silver, and lustrous gold hues dance in perfect balance, highlighting the intricate play of light and shadow cast upon these celestial forms. A halo of diffused radiance envelops each piece, enhancing their textured depths and metallic brilliance while allowing delicate details to emerge from obscurity. The composition conveys a serene yet mesmerizing atmosphere, as if suspended in a dreamlike limbo between reality and fantasy. The tantalizing interplay of colors within this transcendent realm creates a profound sense of depth and grandeur that invites the viewer into an enchanting voyage through abstract metallic beauty. This captivating artwork evokes emotions of boundless curiosity and reverence reminiscent of the timeless works by artists such as Giorgio de Chirico or Paul Klee, while asserting a unique, modern artistic sensibility. With every observation, a new nuance unfolds, as if a never-ending story waiting to be discovered through the lens of metallic artistry.
Negative prompt:
Steps: 25, Sampler: dpmpp_2m_sgm_uniform, Seed: 471889513588087, Model: Fluxmania V5P.safetensors, Model hash: 8ae0583b06, VAE: ae.sft, VAE hash: afc8e28272, Lora_0 Model name: ArtVador I.safetensors, Lora_0 Model hash: 08f7133a58, Lora_0 Strength model: 0.65, Lora_0 Strength clip: 0.65, Lora_1 Model name: Kaoru Yamada.safetensors, Lora_1 Model hash: d4893f7202, Lora_1 Strength model: 0.75, Lora_1 Strength clip: 0.75, Hashes: {"model": "8ae0583b06", "vae": "afc8e28272", "lora:ArtVador I": "08f7133a58", "lora:Kaoru Yamada": "d4893f7202"}
-33
View File
@@ -1,33 +0,0 @@
{
"id": "42803a29-02dc-49e1-b798-27da70e8b408",
"file_path": "/home/miao/workspace/ComfyUI/models/loras/recipes/test/42803a29-02dc-49e1-b798-27da70e8b408.webp",
"title": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect",
"modified": 1754897325.0507245,
"created_date": 1754897325.0507245,
"base_model": "Illustrious",
"loras": [
{
"file_name": "",
"hash": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a",
"strength": 1.0,
"modelVersionId": 2007092,
"modelName": "Pony: People's Works +",
"modelVersionName": "v8_Illusv1.0",
"isDeleted": false,
"exclude": false
}
],
"gen_params": {
"prompt": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect eyes, realistic eyes,\n(flat colors:1.5), (anime:1.5), (lineart:1.5),\nclose-up, solo, tongue, 1girl, food, (saliva:0.1), open mouth, candy, simple background, blue background, large lollipop, tongue out, fade background, lips, hand up, holding, looking at viewer, licking, seductive, half-closed eyes,",
"negative_prompt": "shiny skin,",
"steps": 19,
"sampler": "Euler a",
"cfg_scale": 5,
"seed": 1765271748,
"size": "832x1216",
"clip_skip": 2
},
"fingerprint": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a:1.0",
"source_path": "https://civitai.com/images/92427432",
"folder": "test"
}
-42
View File
@@ -1,42 +0,0 @@
{
"id": 2269146,
"modelId": 2004760,
"name": "v1.0 Illustrious",
"nsfwLevel": 1,
"trainedWords": ["PencilSketchDaal"],
"baseModel": "Illustrious",
"description": "<p>Illustrious. Your pencil may vary with your checkpoint. </p>",
"model": {
"name": "Pencil Sketch Anime",
"type": "LORA",
"nsfw": false,
"description": "description",
"tags": ["style"],
"allowNoCredit": true,
"allowCommercialUse": ["Sell"],
"allowDerivatives": true,
"allowDifferentLicense": true
},
"files": [
{
"id": 2161260,
"sizeKB": 223106.37890625,
"name": "Pencil-Sketch-Illustrious.safetensors",
"type": "Model",
"hashes": {
"SHA256": "2C70479CD673B0FE056EAF4FD97C7F33A39F14853805431AC9AB84226ECE3B82"
},
"primary": true,
"downloadUrl": "https://civitai.com/api/download/models/2269146",
"mirrors": {}
}
],
"images": [
{},
{}
],
"creator": {
"username": "Daalis",
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/eb245b49-edc8-4ed6-ad7b-6d61eb8c51de/width=96/Daalis.jpeg"
}
}
-91
View File
@@ -1,91 +0,0 @@
{
"id": 1255556,
"modelId": 1117241,
"name": "v1.0",
"createdAt": "2025-01-08T06:13:08.839Z",
"updatedAt": "2025-01-08T06:28:54.156Z",
"status": "Published",
"publishedAt": "2025-01-08T06:28:54.155Z",
"trainedWords": ["in the style of ppWhimsy"],
"trainingStatus": null,
"trainingDetails": null,
"baseModel": "Flux.1 D",
"baseModelType": "Standard",
"earlyAccessEndsAt": null,
"earlyAccessConfig": null,
"description": null,
"uploadType": "Created",
"usageControl": "Download",
"air": "urn:air:flux1:lora:civitai:1117241@1255556",
"stats": {
"downloadCount": 210,
"ratingCount": 0,
"rating": 0,
"thumbsUpCount": 26
},
"model": {
"name": "Enchanted Whimsy style (Flux)",
"type": "LORA",
"nsfw": false,
"poi": false
},
"files": [
{
"id": 1160774,
"sizeKB": 38828.8125,
"name": "pp-enchanted-whimsy.safetensors",
"type": "Model",
"pickleScanResult": "Success",
"pickleScanMessage": "No Pickle imports",
"virusScanResult": "Success",
"virusScanMessage": null,
"scannedAt": "2025-01-08T06:16:27.731Z",
"metadata": {
"format": "SafeTensor",
"size": null,
"fp": null
},
"hashes": {
"AutoV1": "40CAF049",
"AutoV2": "3202778C3E",
"SHA256": "3202778C3EBE5CF7EBE5FC51561DEAE8611F4362036EB7C02EFA033C705E6240",
"CRC32": "69DCD953",
"BLAKE3": "ED04580DDB1AD36D8B87F4B0800F5930C7E5D4A7269BDC2BE26ED77EA1A34697",
"AutoV3": "BF82986F8597"
},
"primary": true,
"downloadUrl": "https://civitai.com/api/download/models/1255556"
}
],
"images": [
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/707aef9b-36fb-46c2-ac41-adcab539d3a6/width=832/50270101.jpeg",
"nsfwLevel": 1,
"width": 832,
"height": 1216,
"hash": "U7Am@@$^J3%100R;pLR.M]tQ-ps+?wRiVrof",
"type": "image",
"metadata": {
"hash": "U7Am@@$^J3%100R;pLR.M]tQ-ps+?wRiVrof",
"size": 702313,
"width": 832,
"height": 1216
},
"minor": false,
"poi": false,
"meta": {
"prompt": "in the style of ppWhimsy, a close-up of a boy with a crown of ferns and tiny horns, his eyes wide with wonder as a family of glowing hedgehogs nestle in his hands, their spines shimmering with soft pastel colors"
},
"availability": "Public",
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
}
],
"downloadUrl": "https://civitai.com/api/download/models/1255556",
"creator": {
"username": "PixelPawsAI",
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/f3a1aa7c-0159-4dd8-884a-1e7ceb350f96/width=96/PixelPawsAI.jpeg"
}
}
+47 -53
View File
@@ -349,8 +349,8 @@
} }
.progress-percentage { .progress-percentage {
font-size: 1.2em; font-size: var(--text-lg);
font-weight: 600; font-weight: var(--weight-semibold);
color: var(--lora-accent); color: var(--lora-accent);
} }
@@ -365,9 +365,9 @@
.progress-bar { .progress-bar {
height: 100%; height: 100%;
background: linear-gradient(90deg, var(--lora-accent), oklch(from var(--lora-accent) calc(l + 0.1) c h)); background: var(--lora-accent);
border-radius: 4px; border-radius: var(--border-radius-xs);
transition: width 0.3s ease; transition: width var(--transition-base);
} }
/* Progress Stats */ /* Progress Stats */
@@ -389,27 +389,26 @@
} }
.stat-item.success { .stat-item.success {
border-left: 3px solid #00B87A; border-left: 4px solid var(--color-success);
} }
.stat-item.failed { .stat-item.failed {
border-left: 3px solid var(--lora-error); border-left: 4px solid var(--color-error);
} }
.stat-item.skipped { .stat-item.skipped {
border-left: 3px solid var(--lora-warning); border-left: 4px solid var(--color-warning);
} }
.stat-label { .stat-label {
font-size: 0.8em; font-size: var(--text-xs);
color: var(--text-color); color: var(--text-secondary);
opacity: 0.7;
margin-bottom: 4px; margin-bottom: 4px;
} }
.stat-value { .stat-value {
font-size: 1.4em; font-size: var(--text-lg);
font-weight: 600; font-weight: var(--weight-semibold);
color: var(--text-color); color: var(--text-color);
} }
@@ -425,8 +424,7 @@
} }
.current-item-label { .current-item-label {
color: var(--text-color); color: var(--text-secondary);
opacity: 0.7;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -449,27 +447,29 @@
} }
.results-header { .results-header {
text-align: center; display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
} }
.results-icon { .results-icon {
font-size: 3em; font-size: var(--text-xl);
color: #00B87A; color: var(--color-success);
margin-bottom: var(--space-1); flex-shrink: 0;
} }
.results-icon.warning { .results-icon.warning {
color: var(--lora-warning); color: var(--color-warning);
} }
.results-icon.error { .results-icon.error {
color: var(--lora-error); color: var(--color-error);
} }
.results-title { .results-title {
font-size: 1.3em; font-size: var(--text-lg);
font-weight: 600; font-weight: var(--weight-semibold);
color: var(--text-color); color: var(--text-color);
} }
@@ -493,27 +493,26 @@
} }
.result-card.success { .result-card.success {
border-left: 3px solid #00B87A; border-left: 4px solid var(--color-success);
} }
.result-card.failed { .result-card.failed {
border-left: 3px solid var(--lora-error); border-left: 4px solid var(--color-error);
} }
.result-card.skipped { .result-card.skipped {
border-left: 3px solid var(--lora-warning); border-left: 4px solid var(--color-warning);
} }
.result-label { .result-label {
font-size: 0.8em; font-size: var(--text-xs);
color: var(--text-color); color: var(--text-secondary);
opacity: 0.7;
margin-bottom: 4px; margin-bottom: 4px;
} }
.result-value { .result-value {
font-size: 1.4em; font-size: var(--text-lg);
font-weight: 600; font-weight: var(--weight-semibold);
color: var(--text-color); color: var(--text-color);
} }
@@ -527,13 +526,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: var(--space-2);
padding: 10px; padding: var(--space-2);
cursor: pointer; cursor: pointer;
color: var(--lora-accent); color: var(--lora-accent);
font-weight: 500; font-weight: var(--weight-medium);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
transition: background 0.2s; transition: background var(--transition-base);
} }
.details-toggle:hover { .details-toggle:hover {
@@ -541,7 +540,7 @@
} }
.details-toggle i { .details-toggle i {
transition: transform 0.2s; transition: transform var(--transition-base);
} }
.details-toggle.expanded i { .details-toggle.expanded i {
@@ -561,10 +560,10 @@
.result-item { .result-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: var(--space-2);
padding: 10px 12px; padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
font-size: 0.9em; font-size: var(--text-sm);
} }
.result-item:last-child { .result-item:last-child {
@@ -572,28 +571,23 @@
} }
.result-item-status { .result-item-status {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 0.8em; font-size: var(--text-sm);
flex-shrink: 0;
} }
.result-item-status.success { .result-item-status.success {
background: oklch(from #00B87A l c h / 0.2); color: var(--color-success);
color: #00B87A;
} }
.result-item-status.failed { .result-item-status.failed {
background: oklch(from var(--lora-error) l c h / 0.2); color: var(--color-error);
color: var(--lora-error);
} }
.result-item-status.skipped { .result-item-status.skipped {
background: oklch(from var(--lora-warning) l c h / 0.2); color: var(--color-warning);
color: var(--lora-warning);
} }
.result-item-info { .result-item-info {
@@ -610,8 +604,8 @@
} }
.result-item-error { .result-item-error {
font-size: 0.8em; font-size: var(--text-xs);
color: var(--lora-error); color: var(--color-error);
margin-top: 2px; margin-top: 2px;
} }
@@ -661,11 +655,11 @@
/* Completed State */ /* Completed State */
.batch-progress-container.completed .progress-bar { .batch-progress-container.completed .progress-bar {
background: #00B87A; background: var(--color-success);
} }
.batch-progress-container.completed .status-icon { .batch-progress-container.completed .status-icon {
color: #00B87A; color: var(--color-success);
} }
.batch-progress-container.completed .status-icon i { .batch-progress-container.completed .status-icon i {
+2 -1
View File
@@ -1,6 +1,7 @@
/* Style for selected cards */ /* Style for selected cards */
.model-card.selected { .model-card.selected {
box-shadow: 0 0 0 2px var(--lora-accent); outline: 2px solid var(--lora-accent);
outline-offset: -2px;
position: relative; position: relative;
} }
+59
View File
@@ -509,6 +509,50 @@
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */ background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
} }
/* Clickable version count link (shown in group-by-model mode) */
.version-count-link {
display: inline-block;
color: var(--color-accent);
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
font-size: 0.85em;
line-height: 1.4;
margin-top: 2px;
border: 1px solid var(--color-accent-border);
border-radius: var(--border-radius-xs);
padding: 1px 6px;
background: var(--color-accent-subtle);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.version-count-link:hover {
background: var(--color-accent-border);
border-color: var(--color-accent-transparent);
}
/* Medium density adjustments for version count link */
.medium-density .version-count-link {
font-size: 0.8em;
}
.medium-density .badge-version-unit .version-count-link {
max-width: 90px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Compact density adjustments for version count link */
.compact-density .version-count-link {
font-size: 0.75em;
}
.compact-density .badge-version-unit .version-count-link {
max-width: 70px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Version row — flex container for badges + version names */ /* Version row — flex container for badges + version names */
.version-row { .version-row {
display: flex; display: flex;
@@ -690,6 +734,21 @@ body.hide-card-version .hl-badge {
} }
} }
/* Grid-scoped loading overlay (replaces full-page overlay for VirtualScroller refreshes) */
.grid-loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--lora-bg-transparent, oklch(0% 0 0 / 0.3));
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
pointer-events: none;
}
/* Add after the existing .model-card:hover styles */ /* Add after the existing .model-card:hover styles */
@keyframes update-pulse { @keyframes update-pulse {
+27 -27
View File
@@ -5,10 +5,10 @@
position: sticky; /* Keep the sticky position */ position: sticky; /* Keep the sticky position */
top: var(--space-1); top: var(--space-1);
width: 100%; 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); 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-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(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */ 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); z-index: var(--z-overlay);
padding: 12px 0; padding: 12px 0;
box-shadow: var(--shadow-lg); /* Stronger shadow */ box-shadow: var(--shadow-lg); /* Stronger shadow */
@@ -41,7 +41,7 @@
.duplicates-banner i.fa-exclamation-triangle { .duplicates-banner i.fa-exclamation-triangle {
font-size: 18px; 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 { .duplicates-banner .banner-actions {
@@ -70,7 +70,7 @@
.duplicates-banner button.btn-exit-mode:hover { .duplicates-banner button.btn-exit-mode:hover {
background-color: var(--bg-color); 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); transform: translateY(-1px);
} }
@@ -92,7 +92,7 @@
} }
.duplicates-banner button:hover { .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); background: var(--bg-color);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
@@ -117,7 +117,7 @@
/* Duplicate groups */ /* Duplicate groups */
.duplicate-group { .duplicate-group {
position: relative; 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); border-radius: var(--border-radius-base);
padding: 16px; padding: 16px;
margin-bottom: 24px; margin-bottom: 24px;
@@ -152,7 +152,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; 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 { .duplicate-group-header span:last-child {
@@ -180,7 +180,7 @@
} }
.duplicate-group-header button:hover { .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); background: var(--bg-color);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
@@ -235,7 +235,7 @@
} }
.group-toggle-btn:hover { .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); transform: translateY(-1px);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
@@ -247,16 +247,16 @@
} }
.model-card.duplicate:hover { .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 { .model-card.duplicate.latest {
border-style: solid; 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 { .model-card.duplicate-selected {
border: 2px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h)); border: 2px solid oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
@@ -276,7 +276,7 @@
position: absolute; position: absolute;
top: 10px; top: 10px;
left: 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; color: white;
font-size: 12px; font-size: 12px;
padding: 2px 6px; padding: 2px 6px;
@@ -328,7 +328,7 @@
margin-top: 8px; margin-top: 8px;
padding-top: 8px; padding-top: 8px;
border-top: 1px dashed var(--border-color); 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; font-weight: bold;
word-break: break-all; /* Ensure long hashes wrap properly */ word-break: break-all; /* Ensure long hashes wrap properly */
} }
@@ -351,12 +351,12 @@
} }
.verification-badge.verified { .verification-badge.verified {
background-color: oklch(70% 0.2 140); /* Green for verified */ background-color: var(--color-success); /* Green for verified */
color: white; color: white;
} }
.verification-badge.mismatch { .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; color: white;
} }
@@ -366,7 +366,7 @@
/* Hash Mismatch Styling */ /* Hash Mismatch Styling */
.model-card.duplicate.hash-mismatch { .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; opacity: 0.85;
position: relative; position: relative;
} }
@@ -380,8 +380,8 @@
bottom: 0; bottom: 0;
background: repeating-linear-gradient( background: repeating-linear-gradient(
45deg, 45deg,
oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h) / 0.05), oklch(var(--color-warning-l) var(--color-warning-c) var(--color-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) 10px,
transparent 10px, transparent 10px,
transparent 20px transparent 20px
); );
@@ -398,7 +398,7 @@
position: absolute; position: absolute;
top: 10px; top: 10px;
left: 10px; /* Changed from right:10px to left: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; color: white;
font-size: 12px; font-size: 12px;
padding: 3px 8px; padding: 3px 8px;
@@ -417,7 +417,7 @@
margin-top: 8px; margin-top: 8px;
padding-top: 8px; padding-top: 8px;
border-top: 1px dashed var(--border-color); 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; font-weight: bold;
} }
@@ -437,7 +437,7 @@
.btn-verify-hashes:hover { .btn-verify-hashes:hover {
background: var(--bg-color); 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); transform: translateY(-1px);
} }
@@ -498,7 +498,7 @@
.help-icon:hover { .help-icon:hover {
opacity: 1; 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 */ /* Help tooltip */
@@ -573,7 +573,7 @@
/* In dark mode, add additional distinction */ /* In dark mode, add additional distinction */
html[data-theme="dark"] .duplicates-banner { html[data-theme="dark"] .duplicates-banner {
box-shadow: var(--shadow-dark-lg); /* Stronger shadow in dark mode */ box-shadow: var(--shadow-dark-lg); /* Stronger shadow in dark mode */
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); /* Slightly stronger background in dark mode */ background-color: oklch(var(--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 { html[data-theme="dark"] .duplicate-group {
@@ -598,11 +598,11 @@ html[data-theme="dark"] .help-tooltip {
background: var(--lora-accent); background: var(--lora-accent);
color: white; color: white;
border-color: var(--lora-accent); 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; position: relative;
z-index: 5; z-index: 5;
} }
#findDuplicatesBtn.active:hover { #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));
} }
+271 -7
View File
@@ -149,7 +149,7 @@
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
padding-left: 2.25rem !important; padding-left: 2.25rem !important;
padding-right: 5rem !important; padding-right: 6.75rem !important; /* clear room for options + filter + clear/cue toggles */
border: none; border: none;
background: transparent; background: transparent;
color: var(--text-color); color: var(--text-color);
@@ -190,6 +190,81 @@
right: 2.25rem; right: 2.25rem;
} }
/* Clear button: sit immediately left of the search-options toggle */
.header-search .search-clear {
position: absolute;
right: 4.25rem; /* 2.25rem (options toggle) + 28px toggle width + 4px gap */
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
display: none;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--border-radius-xs, 4px);
padding: 0;
line-height: 1;
transition: background-color var(--transition-base), color var(--transition-base);
}
.header-search .search-clear.visible {
display: flex;
}
.header-search .search-clear:hover {
background: color-mix(in oklch, var(--text-muted) 15%, transparent);
color: var(--lora-accent);
}
/* Keyboard shortcut cue: shown when search is empty, hidden when typing */
.header-search .search-shortcut-cue {
position: absolute;
right: 4.25rem; /* same slot as clear button */
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 2px;
pointer-events: none;
font-family: inherit;
font-size: 0.7rem;
line-height: 1;
color: var(--text-muted);
opacity: 0.7;
white-space: nowrap;
transition: opacity 0.2s ease;
}
.header-search .search-shortcut-cue kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
font-family: inherit;
font-size: 0.68rem;
font-weight: 500;
color: var(--text-muted);
/* Subtle tint derived from text color so it adapts to both light & dark themes */
background: color-mix(in oklch, var(--text-muted) 12%, transparent);
border: 1px solid color-mix(in oklch, var(--text-muted) 25%, transparent);
border-radius: var(--border-radius-xs, 3px);
line-height: 1;
}
.header-search .search-shortcut-cue.hidden {
display: none;
}
.header-search.disabled .search-shortcut-cue {
display: none;
}
.header-search .search-options-toggle:hover, .header-search .search-options-toggle:hover,
.header-search .search-filter-toggle:hover, .header-search .search-filter-toggle:hover,
.header-search .search-filter-toggle:focus-visible { .header-search .search-filter-toggle:focus-visible {
@@ -283,7 +358,6 @@
.theme-toggle { .theme-toggle {
position: relative; position: relative;
/* Ensure relative positioning for the container */
} }
.theme-toggle .light-icon, .theme-toggle .light-icon,
@@ -293,17 +367,14 @@
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
/* Center perfectly */
opacity: 0; opacity: 0;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
/* Default state shows dark icon */
.theme-toggle .dark-icon { .theme-toggle .dark-icon {
opacity: 1; opacity: 1;
} }
/* Light theme shows light icon */
.theme-toggle.theme-light .light-icon { .theme-toggle.theme-light .light-icon {
opacity: 1; opacity: 1;
} }
@@ -313,7 +384,6 @@
opacity: 0; opacity: 0;
} }
/* Dark theme shows dark icon */
.theme-toggle.theme-dark .dark-icon { .theme-toggle.theme-dark .dark-icon {
opacity: 1; opacity: 1;
} }
@@ -323,7 +393,6 @@
opacity: 0; opacity: 0;
} }
/* Auto theme shows auto icon */
.theme-toggle.theme-auto .auto-icon { .theme-toggle.theme-auto .auto-icon {
opacity: 1; opacity: 1;
} }
@@ -333,6 +402,201 @@
opacity: 0; 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 */ /* Badge styling */
.update-badge { .update-badge {
position: absolute; position: absolute;
+4 -4
View File
@@ -211,7 +211,7 @@
.lora-item.is-early-access { .lora-item.is-early-access {
background: rgba(0, 184, 122, 0.05); background: rgba(0, 184, 122, 0.05);
border-left: 4px solid #00B87A; border-left: 4px solid var(--color-success);
} }
.lora-item.missing-locally { .lora-item.missing-locally {
@@ -310,7 +310,7 @@
.missing-lora-item.is-early-access { .missing-lora-item.is-early-access {
background: rgba(0, 184, 122, 0.05); background: rgba(0, 184, 122, 0.05);
border-left: 3px solid #00B87A; border-left: 3px solid var(--color-success);
padding-left: 10px; padding-left: 10px;
} }
@@ -630,7 +630,7 @@
gap: 12px; gap: 12px;
padding: 12px 16px; padding: 12px 16px;
background: rgba(0, 184, 122, 0.1); background: rgba(0, 184, 122, 0.1);
border: 1px solid #00B87A; border: 1px solid var(--color-success);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
color: var(--text-color); color: var(--text-color);
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
@@ -646,7 +646,7 @@
/* Specific styling for the early access warning container in import modal */ /* Specific styling for the early access warning container in import modal */
.early-access-warning .warning-icon { .early-access-warning .warning-icon {
color: #00B87A; color: var(--color-success);
font-size: 1.2em; font-size: 1.2em;
} }
+189 -5
View File
@@ -72,6 +72,10 @@
margin-left: auto; margin-left: auto;
} }
.modal-header-actions .license-permissions {
margin-left: auto;
}
.license-restrictions { .license-restrictions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -95,6 +99,41 @@
transform: translateY(-1px); 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 */
.info-grid { .info-grid {
display: grid; display: grid;
@@ -405,16 +444,161 @@
flex: 1; flex: 1;
} }
.base-model-selector { /* ── Base Model Search Dropdown ─────────────────────────────────────────── */
width: 100%;
padding: 3px 5px; .base-model-search-wrapper {
position: relative;
flex: 1;
min-width: 0;
z-index: 100;
}
.base-model-search-input-wrapper {
display: flex;
align-items: center;
background: var(--bg-color); background: var(--bg-color);
border: 1px solid var(--lora-accent); border: 1px solid var(--lora-accent);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: 0 6px;
gap: 4px;
}
.base-model-search-input-wrapper .search-icon {
color: var(--text-color);
opacity: 0.45;
font-size: 12px;
flex-shrink: 0;
pointer-events: none;
/* Reset global .search-icon rules from search-filter.css */
position: static;
right: auto;
top: auto;
transform: none;
}
.base-model-search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-color); color: var(--text-color);
font-size: 0.9em; font-size: 0.9em;
outline: none; padding: 3px 0;
margin-right: var(--space-1); width: 100%;
min-width: 0;
}
.base-model-search-input::placeholder {
color: var(--text-color);
opacity: 0.35;
}
.base-model-dropdown {
position: absolute;
top: 100%;
left: -1px;
right: -1px;
max-height: 270px;
overflow-y: auto;
background: var(--bg-color);
border: 1px solid var(--lora-border);
border-top: none;
border-radius: 0 0 var(--border-radius-xs) var(--border-radius-xs);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22);
z-index: 101;
}
[data-theme="dark"] .base-model-dropdown {
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
}
/* Dropdown scrollbar styling */
.base-model-dropdown::-webkit-scrollbar {
width: 6px;
}
.base-model-dropdown::-webkit-scrollbar-thumb {
background: var(--lora-border);
border-radius: 3px;
}
.base-model-dropdown::-webkit-scrollbar-track {
background: transparent;
}
/* Section */
.base-model-dropdown-section {
border-bottom: 1px solid var(--lora-border);
}
.base-model-dropdown-section:last-child {
border-bottom: none;
}
/* Section header */
.base-model-dropdown-header {
padding: 5px 10px;
font-size: 0.72em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-color);
opacity: 0.5;
background: var(--surface-subtle);
position: sticky;
top: 0;
z-index: 1;
}
.base-model-dropdown-header.suggested-header {
color: var(--lora-accent);
opacity: 1;
background: oklch(from var(--lora-accent) l c h / 0.08);
}
.base-model-dropdown-header.suggested-header i {
margin-right: 4px;
font-size: 0.85em;
}
/* Dropdown items */
.base-model-dropdown-item {
padding: 5px 12px;
cursor: pointer;
font-size: 0.9em;
color: var(--text-color);
transition: background 0.1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.base-model-dropdown-item:hover {
background: oklch(from var(--lora-accent) l c h / 0.1);
}
.base-model-dropdown-item.active {
background: oklch(from var(--lora-accent) l c h / 0.16);
}
.base-model-dropdown-item.selected {
font-weight: 600;
}
.base-model-dropdown-item.selected::after {
content: '✓';
float: right;
color: var(--lora-accent);
margin-left: 8px;
}
/* Empty state */
.base-model-dropdown-empty {
padding: 18px 12px;
text-align: center;
color: var(--text-color);
opacity: 0.4;
font-size: 0.88em;
} }
.size-wrapper { .size-wrapper {
+38 -9
View File
@@ -229,6 +229,19 @@
gap: 10px; gap: 10px;
} }
/* Header row for params section */
.metadata-row.params-row {
flex-direction: column;
}
.metadata-row.params-row .param-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
/* Styling for parameters tags */ /* Styling for parameters tags */
.params-tags { .params-tags {
display: flex; display: flex;
@@ -272,13 +285,25 @@
margin-top: var(--space-2); margin-top: var(--space-2);
} }
.metadata-row.prompt-row .param-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.metadata-row.prompt-row .param-actions {
display: flex;
align-items: center;
gap: 4px;
}
.metadata-label { .metadata-label {
font-weight: 600; font-weight: 600;
color: var(--text-color); color: var(--text-color);
opacity: 0.8; opacity: 0.8;
font-size: 0.85em; font-size: 0.85em;
display: block;
margin-bottom: 4px;
} }
.metadata-prompt-wrapper { .metadata-prompt-wrapper {
@@ -286,7 +311,7 @@
background: var(--lora-surface); background: var(--lora-surface);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: 6px 30px 6px 8px; padding: 6px 8px;
margin-top: 2px; margin-top: 2px;
max-height: 80px; /* Reduced from 120px */ max-height: 80px; /* Reduced from 120px */
overflow-y: auto; overflow-y: auto;
@@ -302,22 +327,26 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
.copy-prompt-btn { .copy-prompt-btn,
position: absolute; .send-prompt-btn,
top: 6px; .send-params-btn {
right: 6px;
background: transparent; background: transparent;
border: none; border: none;
color: var(--text-color); color: var(--text-color);
opacity: 0.6; opacity: 0.6;
cursor: pointer; cursor: pointer;
padding: 3px; padding: 3px 6px;
border-radius: var(--border-radius-xs);
transition: var(--transition-base); transition: var(--transition-base);
font-size: 0.9em;
} }
.copy-prompt-btn:hover { .copy-prompt-btn:hover,
.send-prompt-btn:hover,
.send-params-btn:hover {
opacity: 1; opacity: 1;
color: var(--lora-accent); color: var(--lora-accent);
background: var(--lora-surface);
} }
/* Scrollbar styling for metadata panel */ /* Scrollbar styling for metadata panel */
+8 -1
View File
@@ -17,6 +17,8 @@
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 6px; gap: 6px;
align-items: center; align-items: center;
min-width: 0;
overflow: hidden;
} }
.model-tag-compact { .model-tag-compact {
@@ -28,6 +30,9 @@
font-size: 0.75em; font-size: 0.75em;
color: var(--text-color); color: var(--text-color);
white-space: nowrap; white-space: nowrap;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
} }
/* Style for empty tags placeholder */ /* Style for empty tags placeholder */
@@ -118,8 +123,9 @@
/* Model Tags Edit Mode */ /* Model Tags Edit Mode */
.model-tags-header { .model-tags-header {
display: flex; display: flex;
justify-content: space-between; justify-content: flex-start;
align-items: center; align-items: center;
overflow: hidden;
} }
.edit-tags-btn { .edit-tags-btn {
@@ -132,6 +138,7 @@
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
transition: var(--transition-base); transition: var(--transition-base);
margin-left: var(--space-1); margin-left: var(--space-1);
flex-shrink: 0;
} }
.edit-tags-btn.visible, .edit-tags-btn.visible,
@@ -24,11 +24,6 @@
min-width: 130px; min-width: 130px;
} }
.stat-card > i {
font-size: 1.25em;
flex-shrink: 0;
}
.stat-card-body { .stat-card-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -52,40 +47,20 @@
border-left-color: var(--color-success); border-left-color: var(--color-success);
} }
.stat-card-success > i {
color: var(--color-success);
}
.stat-card-failure { .stat-card-failure {
border-left-color: var(--color-error); border-left-color: var(--color-error);
} }
.stat-card-failure > i {
color: var(--color-error);
}
.stat-card-skipped { .stat-card-skipped {
border-left-color: var(--color-warning); border-left-color: var(--color-warning);
} }
.stat-card-skipped > i {
color: var(--color-warning);
}
.stat-card-total { .stat-card-total {
border-left-color: var(--color-info); border-left-color: var(--lora-border);
}
.stat-card-total > i {
color: var(--color-info);
} }
.stat-card-time { .stat-card-time {
border-left-color: var(--color-accent); border-left-color: var(--lora-border);
}
.stat-card-time > i {
color: var(--color-accent);
} }
.refresh-failures-section { .refresh-failures-section {
@@ -122,7 +97,7 @@
position: sticky; position: sticky;
top: 0; top: 0;
background: var(--lora-surface); background: var(--lora-surface);
border-bottom: 2px solid var(--lora-border); border-bottom: 1px solid var(--lora-border);
padding: var(--space-1) var(--space-2); padding: var(--space-1) var(--space-2);
text-align: left; text-align: left;
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
@@ -822,3 +822,65 @@
[data-theme="dark"] .batch-preview-item { [data-theme="dark"] .batch-preview-item {
background: var(--lora-surface); background: var(--lora-surface);
} }
.hf-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 8px;
background: oklch(0.55 0.12 250 / 0.15);
color: oklch(0.7 0.12 250);
font-size: 0.75em;
font-weight: 600;
margin-left: 4px;
}
/* Checkbox inside HF batch preview items */
.batch-preview-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--lora-accent);
flex-shrink: 0;
padding: 0;
border: none;
margin: 0;
}
/* Select All toolbar in batch preview */
.batch-preview-select-all {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
background: var(--lora-surface);
cursor: pointer;
position: sticky;
top: 0;
z-index: 1;
}
.batch-preview-select-all input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--lora-accent);
flex-shrink: 0;
padding: 0;
border: none;
margin: 0;
}
.batch-preview-select-all label {
cursor: pointer;
font-size: 0.9em;
color: var(--text-color);
font-weight: 500;
margin: 0;
user-select: none;
}
[data-theme="dark"] .batch-preview-select-all {
background: var(--lora-surface);
}
+100 -2
View File
@@ -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 { .api-key-input {
width: 100%; /* Take full width of parent */ width: 100%; /* Take full width of parent */
position: relative; position: relative;
@@ -345,7 +350,7 @@
.api-key-input input { .api-key-input input {
width: 100%; width: 100%;
padding: 6px 40px 6px 10px; /* Add left padding */ padding: 6px 40px 6px 10px; /* Right padding for eye button */
height: 32px; height: 32px;
box-sizing: border-box; box-sizing: border-box;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
@@ -353,6 +358,13 @@
background-color: var(--lora-surface); background-color: var(--lora-surface);
color: var(--text-color); color: var(--text-color);
font-size: 0.95em; font-size: 0.95em;
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 { .api-key-input .toggle-visibility {
@@ -364,12 +376,98 @@
opacity: 0.6; opacity: 0.6;
cursor: pointer; cursor: pointer;
padding: 4px 8px; padding: 4px 8px;
transition: opacity 0.2s ease;
} }
.api-key-input .toggle-visibility:hover { .api-key-input .toggle-visibility:hover {
opacity: 1; 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 styles for consistent input styling */
.text-input-wrapper { .text-input-wrapper {
width: 100%; width: 100%;
+5 -117
View File
@@ -9,6 +9,10 @@
position: relative; position: relative;
} }
#recipeTagsContainer {
width: 100%;
}
.recipe-modal-header h2 { .recipe-modal-header h2 {
margin: 0 0 var(--space-1); margin: 0 0 var(--space-1);
padding: var(--space-1); padding: var(--space-1);
@@ -95,127 +99,11 @@
min-width: 0; min-width: 0;
} }
.content-editor.tags-editor input {
font-size: 0.9em;
}
/* Remove obsolete button styles */ /* Remove obsolete button styles */
.editor-actions { .editor-actions {
display: none; 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: 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;
}
[data-theme="dark"] .recipe-tag-compact {
background: var(--surface-subtle);
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: var(--shadow-dropdown);
padding: 10px 14px;
max-width: 400px;
z-index: 10;
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: var(--transition-base);
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: var(--surface-hover);
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: var(--surface-hover);
border: 1px solid var(--lora-border);
}
#recipeModal .modal-content { #recipeModal .modal-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1153,7 +1041,7 @@
max-height: 2.4em; max-height: 2.4em;
} }
.recipe-tags-container { #recipeTagsContainer {
margin-bottom: 6px; margin-bottom: 6px;
} }
+18 -4
View File
@@ -114,11 +114,10 @@
.sidebar-hidden-indicator { .sidebar-hidden-indicator {
position: fixed; position: fixed;
left: 0; left: 0;
top: 50%; top: 68px; /* Align with sidebar header */
transform: translateY(-50%);
z-index: var(--z-overlay); z-index: var(--z-overlay);
width: 14px; width: 14px;
height: 44px; height: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -135,7 +134,7 @@
} }
.sidebar-hidden-indicator i { .sidebar-hidden-indicator i {
font-size: 9px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
transition: color 0.15s ease; transition: color 0.15s ease;
} }
@@ -144,6 +143,21 @@
color: white; 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 { .sidebar-hidden-indicator-tooltip {
position: absolute; position: absolute;
left: 100%; left: 100%;
+168
View File
@@ -264,6 +264,174 @@
box-shadow: 0 0 0 2px oklch(var(--lora-accent) / 0.15); box-shadow: 0 0 0 2px oklch(var(--lora-accent) / 0.15);
} }
/* Disabled sort dropdown — used when VLM custom filter is active */
.control-group select:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--bg-color);
border-color: var(--border-color);
box-shadow: none;
transform: none;
}
.control-group select:disabled:hover {
border-color: var(--border-color);
background-color: var(--bg-color);
transform: none;
box-shadow: none;
}
/* === Sort dropdown decoupled trigger width ===========================
The native <select> sizes its trigger to the widest <option>, wasting
horizontal space when a short option is selected. This custom trigger
sizes to the currently selected text only; the dropdown menu sizes to
its content independently. The native <select> is kept in the DOM
(visually hidden) so existing JS that reads/writes `.value` / `.disabled`
and dynamically adds/removes <option>s keeps working. */
.sort-dropdown-group {
position: relative;
display: flex;
}
.sort-trigger {
display: flex;
align-items: center;
gap: 6px;
min-width: 100px;
max-width: 240px;
padding: 4px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background: var(--card-bg);
color: var(--text-color);
font-size: 0.85em;
cursor: pointer;
transition: var(--transition-base);
box-shadow: var(--shadow-xs);
}
.sort-trigger:hover,
.sort-trigger:focus-visible {
border-color: var(--lora-accent);
background: var(--bg-color);
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
outline: none;
}
.sort-trigger:active {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
.sort-trigger__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sort-trigger__caret {
opacity: 0.8;
transition: transform var(--transition-base);
flex-shrink: 0;
}
.sort-dropdown-group.active .sort-trigger__caret {
transform: rotate(180deg);
}
.sort-dropdown-group.active .sort-trigger {
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px color-mix(in oklch, var(--lora-accent) 15%, transparent);
}
/* Disabled state — mirrors the native :disabled look (used when VLM is active) */
.sort-dropdown-group.is-disabled .sort-trigger {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
background: var(--bg-color);
border-color: var(--border-color);
box-shadow: none;
transform: none;
}
/* Dropdown menu sizes to its content, independent of trigger width.
Inherits base .dropdown-menu styling; capped for very long i18n text. */
.sort-dropdown-menu {
min-width: max-content;
max-width: 320px;
width: max-content;
}
/* Optgroup label rendered as a section header */
.sort-dropdown-group .sort-optgroup-label {
padding: 8px 12px 4px;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
cursor: default;
user-select: none;
}
.sort-dropdown-group .sort-optgroup-label:first-child {
padding-top: 4px;
}
/* Option items */
.sort-dropdown-group .sort-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
color: var(--text-color);
cursor: pointer;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.sort-dropdown-group .sort-option::before {
content: '';
width: 14px;
flex-shrink: 0;
text-align: center;
font-weight: 700;
}
.sort-dropdown-group .sort-option:hover {
background-color: color-mix(in oklch, var(--lora-accent) 10%, transparent);
}
.sort-dropdown-group .sort-option.is-selected {
color: var(--lora-accent);
font-weight: 600;
}
.sort-dropdown-group .sort-option.is-selected::before {
content: '\2713';
color: var(--lora-accent);
}
/* Visually hidden native <select> kept in the DOM for programmatic access.
High-specificity selector overrides .control-group select { min-width: 100px }. */
.control-group .sort-select-native {
position: absolute;
width: 1px;
height: 1px;
min-width: 0;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
opacity: 0;
pointer-events: none;
}
/* Ensure hidden class works properly */ /* Ensure hidden class works properly */
.hidden { .hidden {
display: none !important; display: none !important;
+6 -6
View File
@@ -27,8 +27,8 @@
transition: var(--transition-slow); transition: var(--transition-slow);
/* Add glow effect */ /* Add glow effect */
box-shadow: box-shadow:
0 0 0 2px rgba(24, 144, 255, 0.3), 0 0 0 2px color-mix(in oklch, var(--color-accent) 30%, transparent),
0 0 20px rgba(24, 144, 255, 0.2), 0 0 20px color-mix(in oklch, var(--color-accent) 20%, transparent),
inset 0 0 0 1px rgba(255, 255, 255, 0.1); inset 0 0 0 1px rgba(255, 255, 255, 0.1);
} }
@@ -221,14 +221,14 @@
@keyframes onboarding-pulse { @keyframes onboarding-pulse {
0%, 100% { 0%, 100% {
box-shadow: box-shadow:
0 0 0 2px rgba(24, 144, 255, 0.4), 0 0 0 2px color-mix(in oklch, var(--color-accent) 40%, transparent),
0 0 20px rgba(24, 144, 255, 0.3), 0 0 20px color-mix(in oklch, var(--color-accent) 30%, transparent),
inset 0 0 0 1px rgba(255, 255, 255, 0.1); inset 0 0 0 1px rgba(255, 255, 255, 0.1);
} }
50% { 50% {
box-shadow: box-shadow:
0 0 0 4px rgba(24, 144, 255, 0.6), 0 0 0 4px color-mix(in oklch, var(--color-accent) 60%, transparent),
0 0 30px rgba(24, 144, 255, 0.4), 0 0 30px color-mix(in oklch, var(--color-accent) 40%, transparent),
inset 0 0 0 1px rgba(255, 255, 255, 0.2); inset 0 0 0 1px rgba(255, 255, 255, 0.2);
} }
} }
+2
View File
@@ -59,3 +59,5 @@
.initialization-notice .loading-spinner { .initialization-notice .loading-spinner {
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
/* ---------- reused from shared styles ---------- */
+357 -9
View File
@@ -37,13 +37,13 @@
--color-error-border: color-mix(in oklch, var(--color-error) 50%, transparent); --color-error-border: color-mix(in oklch, var(--color-error) 50%, transparent);
--color-info: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h)); --color-info: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h));
--color-info-bg: oklch(72% 0.2 220); --color-info-bg: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h));
--color-info-text: oklch(28% 0.03 220); --color-info-text: oklch(28% 0.03 var(--color-info-h));
--color-info-glow: oklch(72% 0.2 220 / 0.28); --color-info-glow: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h) / 0.28);
--color-skip-refresh-bg: oklch(82% 0.12 45); --color-skip-refresh-bg: oklch(82% 0.12 var(--color-warning-h));
--color-skip-refresh-text: oklch(35% 0.02 45); --color-skip-refresh-text: oklch(35% 0.02 var(--color-warning-h));
--color-skip-refresh-glow: oklch(82% 0.12 45 / 0.15); --color-skip-refresh-glow: oklch(82% 0.12 var(--color-warning-h) / 0.15);
} }
:root { :root {
@@ -106,12 +106,360 @@
--status-info-bg: oklch(50% 0.10 190 / 0.25); --status-info-bg: oklch(50% 0.10 190 / 0.25);
--status-info-border: oklch(55% 0.12 195 / 0.3); --status-info-border: oklch(55% 0.12 195 / 0.3);
--color-info-bg: oklch(62% 0.18 220); --color-info-bg: oklch(62% 0.18 var(--color-info-h));
--color-info-text: oklch(98% 0.02 240); --color-info-text: oklch(98% 0.02 var(--color-info-h));
--color-info-glow: oklch(62% 0.18 220 / 0.4); --color-info-glow: oklch(62% 0.18 var(--color-info-h) / 0.4);
--color-error-bg: color-mix(in oklch, var(--color-error) 15%, transparent); --color-error-bg: color-mix(in oklch, var(--color-error) 15%, transparent);
--color-error-border: color-mix(in oklch, var(--color-error) 40%, transparent); --color-error-border: color-mix(in oklch, var(--color-error) 40%, transparent);
--favorite-color: #ffc107; --favorite-color: #ffc107;
} }
/* ── Preset: Nord ──────────────────────────────────────────── */
[data-theme-preset="nord"] {
--color-accent-h: 213;
--color-accent-c: 0.18;
--color-accent-l: 62%;
--color-warning-h: 35;
--color-warning-c: 0.18;
--color-success-h: 130;
--color-error-l: 62%;
--color-error-c: 0.22;
--color-error-h: 5;
--color-info-h: 195;
--color-info-c: 0.18;
--bg-base: oklch(96% 0.01 240);
--bg-elevated: oklch(98% 0.008 240 / 0.95);
--bg-hover: oklch(93% 0.02 240);
--bg-disabled: oklch(92% 0.01 240);
--text-primary: oklch(22% 0.03 260);
--text-secondary: oklch(48% 0.03 260);
--text-inverse: oklch(97% 0.01 240);
--surface-base: oklch(97% 0.01 240);
--surface-elevated: oklch(98% 0.008 240 / 0.95);
--surface-hover: oklch(93% 0.02 240);
--surface-subtle: oklch(0% 0 0 / 0.03);
--border-base: oklch(82% 0.03 240);
--border-subtle: oklch(82% 0.03 240 / 0.45);
--favorite-color: oklch(72% 0.14 85);
--favorite-glow: oklch(72% 0.14 85 / 0.5);
}
[data-theme="dark"][data-theme-preset="nord"] {
--color-accent-h: 213;
--color-accent-c: 0.18;
--color-accent-l: 68%;
--color-warning-h: 35;
--color-warning-c: 0.18;
--color-success-h: 130;
--color-error-l: 65%;
--color-error-c: 0.22;
--color-error-h: 5;
--color-info-h: 195;
--color-info-c: 0.18;
--bg-base: oklch(20% 0.03 260);
--bg-elevated: oklch(24% 0.03 260 / 0.98);
--bg-hover: oklch(30% 0.03 260);
--bg-disabled: oklch(30% 0.02 260);
--text-primary: oklch(87% 0.02 240);
--text-secondary: oklch(68% 0.02 240);
--text-inverse: oklch(20% 0.03 260);
--surface-base: oklch(26% 0.03 260);
--surface-elevated: oklch(24% 0.03 260 / 0.98);
--surface-hover: oklch(30% 0.03 260);
--surface-subtle: oklch(100% 0 0 / 0.03);
--border-base: oklch(38% 0.03 260);
--border-subtle: oklch(87% 0.02 240 / 0.15);
--favorite-color: oklch(78% 0.15 85);
--favorite-glow: oklch(78% 0.15 85 / 0.5);
}
/* ── Preset: Midnight ───────────────────────────────────────── */
[data-theme-preset="midnight"] {
--color-accent-h: 300;
--color-accent-c: 0.15;
--color-accent-l: 52%;
--color-warning-h: 50;
--color-warning-c: 0.18;
--color-success-h: 135;
--color-error-h: 5;
--color-error-l: 62%;
--color-error-c: 0.22;
--color-info-h: 195;
--color-info-c: 0.12;
--bg-base: oklch(96% 0.01 255);
--bg-elevated: oklch(98% 0.008 255 / 0.95);
--bg-hover: oklch(93% 0.02 255);
--bg-disabled: oklch(92% 0.01 255);
--text-primary: oklch(22% 0.03 260);
--text-secondary: oklch(48% 0.03 260);
--text-inverse: oklch(97% 0.01 255);
--surface-base: oklch(97% 0.01 255);
--surface-elevated: oklch(98% 0.008 255 / 0.95);
--surface-hover: oklch(93% 0.02 255);
--surface-subtle: oklch(0% 0 0 / 0.03);
--border-base: oklch(80% 0.03 255);
--border-subtle: oklch(80% 0.03 255 / 0.45);
--favorite-color: oklch(72% 0.16 85);
--favorite-glow: oklch(72% 0.16 85 / 0.5);
}
[data-theme="dark"][data-theme-preset="midnight"] {
--color-accent-h: 300;
--color-accent-c: 0.14;
--color-accent-l: 68%;
--color-warning-h: 50;
--color-warning-c: 0.18;
--color-success-h: 135;
--color-error-h: 5;
--color-error-l: 65%;
--color-error-c: 0.22;
--color-info-h: 195;
--color-info-c: 0.12;
--bg-base: oklch(18% 0.03 260);
--bg-elevated: oklch(22% 0.03 260 / 0.98);
--bg-hover: oklch(28% 0.03 260);
--bg-disabled: oklch(28% 0.02 260);
--text-primary: oklch(88% 0.02 255);
--text-secondary: oklch(68% 0.02 255);
--text-inverse: oklch(18% 0.03 260);
--surface-base: oklch(24% 0.03 260);
--surface-elevated: oklch(22% 0.03 260 / 0.98);
--surface-hover: oklch(28% 0.03 260);
--surface-subtle: oklch(100% 0 0 / 0.03);
--border-base: oklch(36% 0.03 260);
--border-subtle: oklch(88% 0.02 255 / 0.15);
--favorite-color: oklch(78% 0.16 85);
--favorite-glow: oklch(78% 0.16 85 / 0.5);
}
/* ── Preset: Monokai ───────────────────────────────────────── */
[data-theme-preset="monokai"] {
--color-accent-h: 190;
--color-accent-c: 0.24;
--color-accent-l: 72%;
--color-warning-h: 50;
--color-warning-c: 0.22;
--color-success-h: 140;
--color-error-l: 60%;
--color-error-c: 0.22;
--color-error-h: 340;
--color-info-h: 250;
--bg-base: oklch(96% 0.01 80);
--bg-elevated: oklch(98% 0.005 80 / 0.95);
--bg-hover: oklch(93% 0.015 80);
--bg-disabled: oklch(92% 0.01 80);
--text-primary: oklch(20% 0.02 100);
--text-secondary: oklch(45% 0.02 100);
--text-inverse: oklch(97% 0.01 80);
--surface-base: oklch(97% 0.008 80);
--surface-elevated: oklch(98% 0.005 80 / 0.95);
--surface-hover: oklch(93% 0.015 80);
--surface-subtle: oklch(0% 0 0 / 0.03);
--border-base: oklch(80% 0.02 80);
--border-subtle: oklch(80% 0.02 80 / 0.45);
--favorite-color: oklch(72% 0.16 85);
--favorite-glow: oklch(72% 0.16 85 / 0.5);
}
[data-theme="dark"][data-theme-preset="monokai"] {
--color-accent-h: 190;
--color-accent-c: 0.24;
--color-accent-l: 72%;
--color-warning-h: 50;
--color-warning-c: 0.22;
--color-success-h: 140;
--color-error-l: 65%;
--color-error-c: 0.22;
--color-error-h: 340;
--color-info-h: 250;
--bg-base: oklch(18% 0.02 100);
--bg-elevated: oklch(22% 0.02 100 / 0.98);
--bg-hover: oklch(28% 0.025 100);
--bg-disabled: oklch(28% 0.015 100);
--text-primary: oklch(90% 0.02 80);
--text-secondary: oklch(70% 0.02 80);
--text-inverse: oklch(18% 0.02 100);
--surface-base: oklch(24% 0.02 100);
--surface-elevated: oklch(22% 0.02 100 / 0.98);
--surface-hover: oklch(28% 0.025 100);
--surface-subtle: oklch(100% 0 0 / 0.03);
--border-base: oklch(36% 0.02 100);
--border-subtle: oklch(90% 0.02 80 / 0.15);
--favorite-color: oklch(78% 0.16 85);
--favorite-glow: oklch(78% 0.16 85 / 0.5);
}
/* ── Preset: Dracula ───────────────────────────────────────── */
[data-theme-preset="dracula"] {
--color-accent-h: 265;
--color-accent-c: 0.24;
--color-accent-l: 68%;
--color-warning-h: 45;
--color-warning-c: 0.22;
--color-success-h: 135;
--color-error-l: 62%;
--color-error-c: 0.22;
--color-error-h: 350;
--color-info-h: 195;
--bg-base: oklch(96% 0.01 290);
--bg-elevated: oklch(98% 0.008 290 / 0.95);
--bg-hover: oklch(93% 0.02 290);
--bg-disabled: oklch(92% 0.01 290);
--text-primary: oklch(22% 0.04 290);
--text-secondary: oklch(48% 0.04 290);
--text-inverse: oklch(97% 0.01 290);
--surface-base: oklch(97% 0.01 290);
--surface-elevated: oklch(98% 0.008 290 / 0.95);
--surface-hover: oklch(93% 0.02 290);
--surface-subtle: oklch(0% 0 0 / 0.03);
--border-base: oklch(80% 0.04 290);
--border-subtle: oklch(80% 0.04 290 / 0.45);
--favorite-color: oklch(72% 0.16 85);
--favorite-glow: oklch(72% 0.16 85 / 0.5);
}
[data-theme="dark"][data-theme-preset="dracula"] {
--color-accent-h: 265;
--color-accent-c: 0.24;
--color-accent-l: 72%;
--color-warning-h: 45;
--color-warning-c: 0.22;
--color-success-h: 135;
--color-error-l: 65%;
--color-error-c: 0.22;
--color-error-h: 350;
--color-info-h: 195;
--bg-base: oklch(18% 0.04 290);
--bg-elevated: oklch(22% 0.04 290 / 0.98);
--bg-hover: oklch(28% 0.04 290);
--bg-disabled: oklch(28% 0.03 290);
--text-primary: oklch(90% 0.02 290);
--text-secondary: oklch(70% 0.03 290);
--text-inverse: oklch(18% 0.04 290);
--surface-base: oklch(24% 0.04 290);
--surface-elevated: oklch(22% 0.04 290 / 0.98);
--surface-hover: oklch(28% 0.04 290);
--surface-subtle: oklch(100% 0 0 / 0.03);
--border-base: oklch(36% 0.04 290);
--border-subtle: oklch(90% 0.02 290 / 0.15);
--favorite-color: oklch(78% 0.16 85);
--favorite-glow: oklch(78% 0.16 85 / 0.5);
}
/* ── Preset: Solarized ─────────────────────────────────────── */
[data-theme-preset="solarized"] {
--color-accent-h: 175;
--color-accent-c: 0.18;
--color-accent-l: 55%;
--color-warning-h: 45;
--color-warning-c: 0.20;
--color-success-h: 68;
--color-error-l: 62%;
--color-error-c: 0.22;
--color-error-h: 5;
--color-info-h: 220;
--color-info-c: 0.16;
--color-info-l: 68%;
--bg-base: oklch(95% 0.03 85);
--bg-elevated: oklch(97% 0.025 85 / 0.95);
--bg-hover: oklch(91% 0.035 85);
--bg-disabled: oklch(90% 0.025 85);
--text-primary: oklch(30% 0.06 200);
--text-secondary: oklch(50% 0.04 200);
--text-inverse: oklch(95% 0.03 85);
--surface-base: oklch(96% 0.025 85);
--surface-elevated: oklch(97% 0.025 85 / 0.95);
--surface-hover: oklch(91% 0.035 85);
--surface-subtle: oklch(0% 0 0 / 0.03);
--border-base: oklch(78% 0.04 85);
--border-subtle: oklch(78% 0.04 85 / 0.45);
--favorite-color: oklch(68% 0.16 75);
--favorite-glow: oklch(68% 0.16 75 / 0.5);
}
[data-theme="dark"][data-theme-preset="solarized"] {
--color-accent-h: 175;
--color-accent-c: 0.18;
--color-accent-l: 60%;
--color-warning-h: 45;
--color-warning-c: 0.20;
--color-success-h: 68;
--color-error-l: 65%;
--color-error-c: 0.22;
--color-error-h: 5;
--color-info-h: 220;
--color-info-c: 0.16;
--color-info-l: 68%;
--bg-base: oklch(18% 0.05 200);
--bg-elevated: oklch(22% 0.05 200 / 0.98);
--bg-hover: oklch(28% 0.05 200);
--bg-disabled: oklch(28% 0.04 200);
--text-primary: oklch(72% 0.03 85);
--text-secondary: oklch(62% 0.03 85);
--text-inverse: oklch(18% 0.05 200);
--surface-base: oklch(24% 0.05 200);
--surface-elevated: oklch(22% 0.05 200 / 0.98);
--surface-hover: oklch(28% 0.05 200);
--surface-subtle: oklch(100% 0 0 / 0.03);
--border-base: oklch(36% 0.04 200);
--border-subtle: oklch(72% 0.03 85 / 0.15);
--favorite-color: oklch(72% 0.16 75);
--favorite-glow: oklch(72% 0.16 75 / 0.5);
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-brush"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 21v-4a4 4 0 1 1 4 4h-4" /><path d="M21 3a16 16 0 0 0 -12.8 10.2" /><path d="M21 3a16 16 0 0 1 -10.2 12.8" /><path d="M10.6 9a9 9 0 0 1 4.4 4.4" /></svg>

After

Width:  |  Height:  |  Size: 460 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-currency-dollar"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M16.7 8a3 3 0 0 0 -2.7 -2h-4a3 3 0 0 0 0 6h4a3 3 0 0 1 0 6h-4a3 3 0 0 1 -2.7 -2" /><path d="M12 3v3m0 12v3" /></svg>

After

Width:  |  Height:  |  Size: 431 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-git-merge"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 18a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M5 6a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M15 12a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M7 8l0 8" /><path d="M7 8a4 4 0 0 0 4 4h4" /></svg>

After

Width:  |  Height:  |  Size: 501 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-license"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 21h-9a3 3 0 0 1 -3 -3v-1h10v2a2 2 0 0 0 4 0v-14a2 2 0 1 1 2 2h-2m2 -4h-11a3 3 0 0 0 -3 3v11" /><path d="M9 7l4 0" /><path d="M9 11l4 0" /></svg>

After

Width:  |  Height:  |  Size: 455 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-user"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg>

After

Width:  |  Height:  |  Size: 401 B

+6
View File
@@ -190,6 +190,12 @@ export const DOWNLOAD_ENDPOINTS = {
exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images
}; };
// Hugging Face API endpoints
export const HF_ENDPOINTS = {
repoFiles: '/api/lm/hf-repo-files',
download: '/api/lm/download-hf-model',
};
// WebSocket endpoints // WebSocket endpoints
export const WS_ENDPOINTS = { export const WS_ENDPOINTS = {
fetchProgress: '/ws/fetch-progress' fetchProgress: '/ws/fetch-progress'
+91 -9
View File
@@ -1,12 +1,13 @@
import { state, getCurrentPageState } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js'; import { getStorageItem, getSessionItem, removeSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
import { import {
getCompleteApiConfig, getCompleteApiConfig,
getCurrentModelType, getCurrentModelType,
isValidModelType, isValidModelType,
DOWNLOAD_ENDPOINTS, DOWNLOAD_ENDPOINTS,
HF_ENDPOINTS,
WS_ENDPOINTS WS_ENDPOINTS
} from './apiConfig.js'; } from './apiConfig.js';
import { resetAndReload } from './modelApiFactory.js'; import { resetAndReload } from './modelApiFactory.js';
@@ -115,7 +116,10 @@ export class BaseModelApiClient {
const pageState = this.getPageState(); const pageState = this.getPageState();
try { try {
state.loadingManager.showSimpleLoading(`Loading more ${this.apiConfig.config.displayName}s...`); // Use grid-scoped loading instead of full-page overlay
if (state.virtualScroller?.showGridLoading) {
state.virtualScroller.showGridLoading();
}
pageState.isLoading = true; pageState.isLoading = true;
if (resetPage) { if (resetPage) {
@@ -133,6 +137,16 @@ export class BaseModelApiClient {
pageState.hasMore = result.hasMore; pageState.hasMore = result.hasMore;
pageState.currentPage = pageState.currentPage + 1; pageState.currentPage = pageState.currentPage + 1;
// When resetting to page 1, scroll back to the top
// This covers: folder selection, filter/sort/search changes,
// favorites/update/excluded view toggles, alphabet filter, etc.
if (resetPage) {
const scrollContainer = document.querySelector('.page-content');
if (scrollContainer) {
scrollContainer.scrollTop = 0;
}
}
if (updateFolders) { if (updateFolders) {
sidebarManager.refresh(); sidebarManager.refresh();
} }
@@ -144,7 +158,14 @@ export class BaseModelApiClient {
throw error; throw error;
} finally { } finally {
pageState.isLoading = false; pageState.isLoading = false;
state.loadingManager.hide(); // Wait for the next rAF so refreshWithData's scheduleRender has
// completed rendering new cards before hiding the grid loading overlay.
// This eliminates the ~6.7ms blank-frame gap that caused the flicker.
if (state.virtualScroller?.hideGridLoading) {
requestAnimationFrame(() => {
state.virtualScroller.hideGridLoading();
});
}
} }
} }
@@ -679,39 +700,34 @@ export class BaseModelApiClient {
<div class="modal-content metadata-refresh-result-modal"> <div class="modal-content metadata-refresh-result-modal">
<button class="close" data-action="close-modal">&times;</button> <button class="close" data-action="close-modal">&times;</button>
<h2><i class="fas fa-sync-alt"></i> ${translate('modals.metadataFetchSummary.title', {}, 'Metadata Fetch Summary')}</h2> <h2>${translate('modals.metadataFetchSummary.title', {}, 'Metadata Fetch Summary')}</h2>
<div class="refresh-summary-stats"> <div class="refresh-summary-stats">
<div class="stat-card stat-card-success"> <div class="stat-card stat-card-success">
<i class="fas fa-check-circle"></i>
<div class="stat-card-body"> <div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSuccess', {}, 'Success')}</span> <span class="stat-card-label">${translate('modals.metadataFetchSummary.statSuccess', {}, 'Success')}</span>
<span class="stat-card-value">${success}</span> <span class="stat-card-value">${success}</span>
</div> </div>
</div> </div>
<div class="stat-card stat-card-failure"> <div class="stat-card stat-card-failure">
<i class="fas fa-times-circle"></i>
<div class="stat-card-body"> <div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statFailed', {}, 'Failed')}</span> <span class="stat-card-label">${translate('modals.metadataFetchSummary.statFailed', {}, 'Failed')}</span>
<span class="stat-card-value">${failure_count}</span> <span class="stat-card-value">${failure_count}</span>
</div> </div>
</div> </div>
<div class="stat-card stat-card-skipped"> <div class="stat-card stat-card-skipped">
<i class="fas fa-forward"></i>
<div class="stat-card-body"> <div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSkipped', {}, 'Skipped')}</span> <span class="stat-card-label">${translate('modals.metadataFetchSummary.statSkipped', {}, 'Skipped')}</span>
<span class="stat-card-value">${skipped_count}</span> <span class="stat-card-value">${skipped_count}</span>
</div> </div>
</div> </div>
<div class="stat-card stat-card-total"> <div class="stat-card stat-card-total">
<i class="fas fa-database"></i>
<div class="stat-card-body"> <div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statTotal', {}, 'Total Scanned')}</span> <span class="stat-card-label">${translate('modals.metadataFetchSummary.statTotal', {}, 'Total Scanned')}</span>
<span class="stat-card-value">${total || processed}</span> <span class="stat-card-value">${total || processed}</span>
</div> </div>
</div> </div>
<div class="stat-card stat-card-time"> <div class="stat-card stat-card-time">
<i class="fas fa-clock"></i>
<div class="stat-card-body"> <div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statDuration', {}, 'Duration')}</span> <span class="stat-card-label">${translate('modals.metadataFetchSummary.statDuration', {}, 'Duration')}</span>
<span class="stat-card-value">${elapsed_seconds}s</span> <span class="stat-card-value">${elapsed_seconds}s</span>
@@ -1228,6 +1244,48 @@ export class BaseModelApiClient {
} }
} }
async fetchHfRepoFiles(repo, revision = 'main') {
try {
const params = new URLSearchParams({ repo, revision });
const response = await fetch(`${HF_ENDPOINTS.repoFiles}?${params}`);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to fetch HF repo files');
}
return await response.json();
} catch (error) {
console.error('Error fetching HF repo files:', error);
throw error;
}
}
async downloadHfModel({ repo, filename, revision, modelRoot, relativePath, useDefaultPaths, download_id }) {
try {
const response = await fetch(HF_ENDPOINTS.download, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
repo,
filename,
revision: revision || 'main',
model_root: modelRoot,
relative_path: relativePath || '',
use_default_paths: useDefaultPaths || false,
...(download_id ? { download_id } : {}),
})
});
if (!response.ok) {
throw new Error(await response.text());
}
return await response.json();
} catch (error) {
console.error('Error downloading HF model:', error);
throw error;
}
}
_buildQueryParams(baseParams, pageState) { _buildQueryParams(baseParams, pageState) {
const params = new URLSearchParams(baseParams); const params = new URLSearchParams(baseParams);
const isExcludedView = pageState.viewMode === 'excluded'; const isExcludedView = pageState.viewMode === 'excluded';
@@ -1266,6 +1324,12 @@ export class BaseModelApiClient {
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false'); params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
// Pass group-by-model mode to backend (skip when showing all versions of a specific model)
const vlmModelId = getSessionItem('vlm_model_id');
if (state.global.settings.group_by_model && !vlmModelId) {
params.append('group_by_model', 'true');
}
if (!isExcludedView && pageState.filters) { if (!isExcludedView && pageState.filters) {
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) { if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
Object.entries(pageState.filters.tags).forEach(([tag, state]) => { Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
@@ -1347,6 +1411,24 @@ export class BaseModelApiClient {
} }
_addModelSpecificParams(params, pageState) { _addModelSpecificParams(params, pageState) {
// Check for View Local Versions filter (takes priority over recipe filters)
const vlmModelId = getSessionItem('vlm_model_id');
const vlmPageType = getSessionItem('vlm_page_type');
if (vlmModelId && vlmPageType === this.modelType) {
params.append('civitai_model_id', vlmModelId);
const vlmBaseModel = getSessionItem('vlm_base_model');
if (vlmBaseModel) {
params.append('base_model', vlmBaseModel);
}
return;
} else if (vlmModelId && vlmPageType !== this.modelType) {
// Stale VLM data from a different page type — clean up
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
}
if (this.modelType === 'loras') { if (this.modelType === 'loras') {
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash'); const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes'); const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
+7
View File
@@ -9,6 +9,13 @@ export class LoraApiClient extends BaseModelApiClient {
* Add LoRA-specific parameters to query * Add LoRA-specific parameters to query
*/ */
_addModelSpecificParams(params, pageState) { _addModelSpecificParams(params, pageState) {
// Let parent handle View Local Versions filter first
super._addModelSpecificParams(params, pageState);
// If VLM filter was applied, skip recipe-specific filters
if (params.has('civitai_model_id')) {
return;
}
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash'); const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes'); const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
@@ -24,6 +24,14 @@ export class GlobalContextMenu extends BaseContextMenu {
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]'); const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]'); const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]'); const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
// Update check indicator for group-by-model
if (groupByModelCheck) {
const isEnabled = !!state.global.settings.group_by_model;
groupByModelCheck.style.display = isEnabled ? 'block' : 'none';
}
if (isRecipesPage) { if (isRecipesPage) {
modelUpdateItem?.classList.add('hidden'); modelUpdateItem?.classList.add('hidden');
@@ -31,6 +39,7 @@ export class GlobalContextMenu extends BaseContextMenu {
downloadExamplesItem?.classList.add('hidden'); downloadExamplesItem?.classList.add('hidden');
cleanupExamplesItem?.classList.add('hidden'); cleanupExamplesItem?.classList.add('hidden');
excludedModelsItem?.classList.add('hidden'); excludedModelsItem?.classList.add('hidden');
groupByModelItem?.classList.add('hidden');
repairRecipesItem?.classList.remove('hidden'); repairRecipesItem?.classList.remove('hidden');
} else { } else {
modelUpdateItem?.classList.remove('hidden'); modelUpdateItem?.classList.remove('hidden');
@@ -38,6 +47,7 @@ export class GlobalContextMenu extends BaseContextMenu {
downloadExamplesItem?.classList.remove('hidden'); downloadExamplesItem?.classList.remove('hidden');
cleanupExamplesItem?.classList.remove('hidden'); cleanupExamplesItem?.classList.remove('hidden');
excludedModelsItem?.classList.remove('hidden'); excludedModelsItem?.classList.remove('hidden');
groupByModelItem?.classList.remove('hidden');
repairRecipesItem?.classList.add('hidden'); repairRecipesItem?.classList.add('hidden');
} }
@@ -74,6 +84,9 @@ export class GlobalContextMenu extends BaseContextMenu {
case 'manage-excluded-models': case 'manage-excluded-models':
this.manageExcludedModels(); this.manageExcludedModels();
break; break;
case 'toggle-group-by-model':
this.toggleGroupByModel();
break;
default: default:
console.warn(`Unhandled global context menu action: ${action}`); console.warn(`Unhandled global context menu action: ${action}`);
break; break;
@@ -86,6 +99,30 @@ export class GlobalContextMenu extends BaseContextMenu {
}); });
} }
toggleGroupByModel() {
const sm = window.settingsManager;
if (!sm) {
console.error('settingsManager not available on window');
return;
}
const newValue = !state.global.settings.group_by_model;
state.global.settings.group_by_model = newValue;
// Save/restore sort preference when toggling group_by_model
if (window.pageControls?.onGroupByModelToggled) {
window.pageControls.onGroupByModelToggled(newValue);
}
sm.saveSetting('group_by_model', newValue).catch((error) => {
console.error('Failed to save group_by_model setting:', error);
// Revert state on failure
state.global.settings.group_by_model = !newValue;
});
sm.applyFrontendSettings();
sm.reloadContent();
}
async downloadExampleImages(menuItem) { async downloadExampleImages(menuItem) {
const downloadPath = state?.global?.settings?.example_images_path; const downloadPath = state?.global?.settings?.example_images_path;
if (!downloadPath) { if (!downloadPath) {
+114 -32
View File
@@ -1,9 +1,9 @@
import { updateService } from '../managers/UpdateService.js'; import { updateService } from '../managers/UpdateService.js';
import { toggleTheme } from '../utils/uiHelpers.js'; import { toggleTheme, setPreset, CYCLE_ORDER, PRESET_NAMES } from '../utils/uiHelpers.js';
import { SearchManager } from '../managers/SearchManager.js'; import { SearchManager } from '../managers/SearchManager.js';
import { FilterManager } from '../managers/FilterManager.js'; import { FilterManager } from '../managers/FilterManager.js';
import { initPageState } from '../state/index.js'; import { initPageState } from '../state/index.js';
import { getStorageItem } from '../utils/storageHelpers.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { updateElementAttribute } from '../utils/i18nHelpers.js'; import { updateElementAttribute } from '../utils/i18nHelpers.js';
import { renderSupporters } from '../services/supportersService.js'; import { renderSupporters } from '../services/supportersService.js';
@@ -47,25 +47,8 @@ export class HeaderManager {
} }
initializeCommonElements() { initializeCommonElements() {
// Handle theme toggle this.initializeThemePopover();
const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) {
const currentTheme = getStorageItem('theme') || 'auto';
themeToggle.classList.add(`theme-${currentTheme}`);
// Use i18nHelpers to update themeToggle's title
this.updateThemeTooltip(themeToggle, currentTheme);
themeToggle.addEventListener('click', async () => {
if (typeof toggleTheme === 'function') {
const newTheme = toggleTheme();
// Use i18nHelpers to update themeToggle's title
this.updateThemeTooltip(themeToggle, newTheme);
}
});
}
// Handle settings toggle
const settingsToggle = document.querySelector('.settings-toggle'); const settingsToggle = document.querySelector('.settings-toggle');
if (settingsToggle) { if (settingsToggle) {
settingsToggle.addEventListener('click', () => { settingsToggle.addEventListener('click', () => {
@@ -75,7 +58,6 @@ export class HeaderManager {
}); });
} }
// Handle update toggle
const updateToggle = document.getElementById('updateToggleBtn'); const updateToggle = document.getElementById('updateToggleBtn');
if (updateToggle) { if (updateToggle) {
updateToggle.addEventListener('click', () => { updateToggle.addEventListener('click', () => {
@@ -83,13 +65,11 @@ export class HeaderManager {
}); });
} }
// Handle support toggle
const supportToggle = document.getElementById('supportToggleBtn'); const supportToggle = document.getElementById('supportToggleBtn');
if (supportToggle) { if (supportToggle) {
supportToggle.addEventListener('click', async () => { supportToggle.addEventListener('click', async () => {
if (window.modalManager) { if (window.modalManager) {
window.modalManager.toggleModal('supportModal'); window.modalManager.toggleModal('supportModal');
// Load supporters data when modal opens
try { try {
await renderSupporters(); await renderSupporters();
} catch (error) { } catch (error) {
@@ -99,19 +79,17 @@ export class HeaderManager {
}); });
} }
// Handle QR code toggle
const qrToggle = document.getElementById('toggleQRCode'); const qrToggle = document.getElementById('toggleQRCode');
const qrContainer = document.getElementById('qrCodeContainer'); const qrContainer = document.getElementById('qrCodeContainer');
if (qrToggle && qrContainer) { if (qrToggle && qrContainer) {
qrToggle.addEventListener('click', function() { qrToggle.addEventListener('click', function () {
qrContainer.classList.toggle('show'); qrContainer.classList.toggle('show');
qrToggle.classList.toggle('active'); qrToggle.classList.toggle('active');
const toggleText = qrToggle.querySelector('.toggle-text'); const toggleText = qrToggle.querySelector('.toggle-text');
if (qrContainer.classList.contains('show')) { if (qrContainer.classList.contains('show')) {
toggleText.textContent = 'Hide WeChat QR Code'; toggleText.textContent = 'Hide WeChat QR Code';
// Add small delay to ensure DOM is updated before scrolling
setTimeout(() => { setTimeout(() => {
const supportModal = document.querySelector('.support-modal'); const supportModal = document.querySelector('.support-modal');
if (supportModal) { if (supportModal) {
@@ -127,13 +105,118 @@ export class HeaderManager {
}); });
} }
// Hide search functionality on Statistics page
this.updateHeaderForPage(); this.updateHeaderForPage();
// Initialize hamburger menu for mobile
this.initializeHamburgerMenu(); this.initializeHamburgerMenu();
} }
initializeThemePopover() {
const themeToggle = document.querySelector('.theme-toggle');
const themePopover = document.getElementById('themePopover');
if (!themeToggle || !themePopover) return;
const currentTheme = getStorageItem('theme') || 'auto';
const currentPreset = getStorageItem('theme_preset') || 'default';
themeToggle.classList.add(`theme-${currentTheme}`);
this.updateThemeTooltip(themeToggle, currentTheme);
this.updatePopoverActiveStates(currentTheme, currentPreset);
themeToggle.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = themePopover.classList.contains('active');
this.closeAllPopovers();
if (!isOpen) {
this.positionThemePopover();
themePopover.classList.add('active');
}
});
themePopover.addEventListener('click', (e) => {
e.stopPropagation();
const modeBtn = e.target.closest('.theme-mode-btn');
const presetBtn = e.target.closest('.theme-preset-btn');
if (modeBtn) {
const mode = modeBtn.dataset.mode;
this.setThemeMode(mode);
} else if (presetBtn) {
const preset = presetBtn.dataset.preset;
this.setThemePreset(preset);
}
});
document.addEventListener('click', (e) => {
if (!themeToggle.contains(e.target) && !themePopover.contains(e.target)) {
themePopover.classList.remove('active');
}
});
// Reposition on resize while popover is active
window.addEventListener('resize', () => {
if (themePopover.classList.contains('active')) {
this.positionThemePopover();
}
});
}
closeAllPopovers() {
const themePopover = document.getElementById('themePopover');
if (themePopover) {
themePopover.classList.remove('active');
}
}
positionThemePopover() {
const themeToggle = document.querySelector('.theme-toggle');
const themePopover = document.getElementById('themePopover');
if (!themeToggle || !themePopover) return;
const rect = themeToggle.getBoundingClientRect();
// Guard: toggle may be hidden on narrow viewports (≤950px CSS hides .header-controls)
if (rect.width === 0 || rect.height === 0) return;
themePopover.style.top = (rect.bottom + 8) + 'px';
themePopover.style.right = (window.innerWidth - rect.right - 8) + 'px';
}
setThemeMode(mode) {
setStorageItem('theme', mode);
const htmlElement = document.documentElement;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
htmlElement.removeAttribute('data-theme');
if (mode === 'dark' || (mode === 'auto' && prefersDark)) {
htmlElement.setAttribute('data-theme', 'dark');
document.body.dataset.theme = 'dark';
} else {
htmlElement.setAttribute('data-theme', 'light');
document.body.dataset.theme = 'light';
}
const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) {
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
themeToggle.classList.add(`theme-${mode}`);
this.updateThemeTooltip(themeToggle, mode);
}
this.updateHamburgerThemeIcon();
this.updatePopoverActiveStates(mode, getStorageItem('theme_preset') || 'default');
}
setThemePreset(preset) {
setPreset(preset);
this.updatePopoverActiveStates(getStorageItem('theme') || 'auto', preset);
this.updateHamburgerThemeIcon();
}
updatePopoverActiveStates(theme, preset) {
const popover = document.getElementById('themePopover');
if (!popover) return;
popover.querySelectorAll('.theme-mode-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === theme);
});
popover.querySelectorAll('.theme-preset-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.preset === preset);
});
}
initializeHamburgerMenu() { initializeHamburgerMenu() {
const hamburgerBtn = document.getElementById('hamburgerMenuBtn'); const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
const hamburgerDropdown = document.getElementById('hamburgerDropdown'); const hamburgerDropdown = document.getElementById('hamburgerDropdown');
@@ -188,7 +271,6 @@ export class HeaderManager {
case 'theme': case 'theme':
if (typeof toggleTheme === 'function') { if (typeof toggleTheme === 'function') {
const newTheme = toggleTheme(); const newTheme = toggleTheme();
// Update theme toggle in header if it exists
const themeToggle = document.querySelector('.theme-toggle'); const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) { if (themeToggle) {
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto'); themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
@@ -196,6 +278,7 @@ export class HeaderManager {
this.updateThemeTooltip(themeToggle, newTheme); this.updateThemeTooltip(themeToggle, newTheme);
} }
this.updateHamburgerThemeIcon(); this.updateHamburgerThemeIcon();
this.updatePopoverActiveStates(newTheme, getStorageItem('theme_preset') || 'default');
} }
break; break;
case 'settings': case 'settings':
@@ -255,7 +338,6 @@ export class HeaderManager {
const headerSearch = document.getElementById('headerSearch'); const headerSearch = document.getElementById('headerSearch');
const searchInput = headerSearch?.querySelector('#searchInput'); const searchInput = headerSearch?.querySelector('#searchInput');
const searchButtons = headerSearch?.querySelectorAll('button'); const searchButtons = headerSearch?.querySelectorAll('button');
const placeholderKey = 'header.search.placeholders.' + this.currentPage;
if (this.currentPage === 'statistics' && headerSearch) { if (this.currentPage === 'statistics' && headerSearch) {
headerSearch.classList.add('disabled'); headerSearch.classList.add('disabled');
@@ -270,7 +352,7 @@ export class HeaderManager {
if (searchInput) { if (searchInput) {
searchInput.disabled = false; searchInput.disabled = false;
// Use i18nHelpers to update placeholder // Use i18nHelpers to update placeholder
updateElementAttribute(searchInput, 'placeholder', placeholderKey, {}, ''); updateElementAttribute(searchInput, 'placeholder', 'header.search.placeholder', {}, '');
} }
searchButtons?.forEach(btn => btn.disabled = false); searchButtons?.forEach(btn => btn.disabled = false);
} }
+93 -251
View File
@@ -1,5 +1,5 @@
// Recipe Modal Component // Recipe Modal Component
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js'; import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
@@ -7,6 +7,8 @@ import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
import { downloadManager } from '../managers/DownloadManager.js'; import { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js'; import { MODEL_TYPES } from '../api/apiConfig.js';
import { openMediaViewer } from './shared/MediaViewer.js'; import { openMediaViewer } from './shared/MediaViewer.js';
import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
import { setupTagEditMode } from './shared/ModelTags.js';
const ALLOWED_GEN_PARAM_KEYS = new Set([ const ALLOWED_GEN_PARAM_KEYS = new Set([
'prompt', 'prompt',
@@ -38,6 +40,16 @@ const GEN_PARAM_NORMALIZATION = {
'Denoising strength': 'denoising_strength', 'Denoising strength': 'denoising_strength',
}; };
const PARAM_DISPLAY_NAMES = {
steps: 'Steps',
sampler: 'Sampler',
cfg_scale: 'CFG',
seed: 'Seed',
size: 'Size',
clip_skip: 'Clip Skip',
denoising_strength: 'Denoising Strength',
};
class RecipeModal { class RecipeModal {
constructor() { constructor() {
this.promptEditorState = {}; this.promptEditorState = {};
@@ -139,14 +151,6 @@ class RecipeModal {
this.saveTitleEdit(); this.saveTitleEdit();
} }
// Handle tags edit
const tagsEditor = document.getElementById('recipeTagsEditor');
if (tagsEditor && tagsEditor.classList.contains('active') &&
!tagsEditor.contains(event.target) &&
!event.target.closest('.edit-icon')) {
this.saveTagsEdit();
}
// Handle reconnect input // Handle reconnect input
const reconnectContainers = document.querySelectorAll('.lora-reconnect-container'); const reconnectContainers = document.querySelectorAll('.lora-reconnect-container');
reconnectContainers.forEach(container => { reconnectContainers.forEach(container => {
@@ -236,98 +240,10 @@ class RecipeModal {
this.filePath = hydratedRecipe.file_path; this.filePath = hydratedRecipe.file_path;
this.listFilePath = hydratedRecipe.file_path; this.listFilePath = hydratedRecipe.file_path;
// Set recipe tags if they exist // Render tags using shared utility
const tagsCompactElement = document.getElementById('recipeTagsCompact'); const tagsContainer = document.getElementById('recipeTagsContainer');
const tagsTooltipContent = document.getElementById('recipeTagsTooltipContent'); if (tagsContainer) {
this.updateTagsDisplay(tagsContainer, hydratedRecipe.tags || []);
if (tagsCompactElement) {
// Add tags container with edit functionality
tagsCompactElement.innerHTML = `
<div class="editable-content tags-content">
<div class="tags-display"></div>
<button class="edit-icon" title="Edit tags"><i class="fas fa-pencil-alt"></i></button>
</div>
<div id="recipeTagsEditor" class="content-editor tags-editor">
<input type="text" class="tags-input" placeholder="Enter tags separated by commas">
</div>
`;
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
// Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5;
const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
// Add visible tags
visibleTags.forEach(tag => {
const tagElement = document.createElement('div');
tagElement.className = 'recipe-tag-compact';
tagElement.textContent = tag;
tagsDisplay.appendChild(tagElement);
});
// Add "more" button if needed
if (remainingTags.length > 0) {
const moreButton = document.createElement('div');
moreButton.className = 'recipe-tag-more';
moreButton.textContent = `+${remainingTags.length} more`;
tagsDisplay.appendChild(moreButton);
// Add tooltip functionality
moreButton.addEventListener('mouseenter', () => {
document.getElementById('recipeTagsTooltip').classList.add('visible');
});
moreButton.addEventListener('mouseleave', () => {
setTimeout(() => {
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
}
}, 300);
});
document.getElementById('recipeTagsTooltip').addEventListener('mouseleave', () => {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
});
// Add all tags to tooltip
if (tagsTooltipContent) {
tagsTooltipContent.innerHTML = '';
hydratedRecipe.tags.forEach(tag => {
const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag;
tagsTooltipContent.appendChild(tooltipTag);
});
}
}
} else {
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
}
// Add event listeners for tags editing
const editTagsIcon = tagsCompactElement.querySelector('.edit-icon');
const tagsInput = tagsCompactElement.querySelector('.tags-input');
// Set current tags in the input
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
tagsInput.value = hydratedRecipe.tags.join(', ');
}
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
// Add key event listener for Enter key
tagsInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.saveTagsEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
this.cancelTagsEdit();
}
});
} }
// Set recipe image // Set recipe image
@@ -609,17 +525,35 @@ class RecipeModal {
} }
syncTagsDisplay(tags) { syncTagsDisplay(tags) {
const tagsContainer = document.getElementById('recipeTagsCompact'); const container = document.getElementById('recipeTagsContainer');
if (!tagsContainer) { if (!container) return;
return; this.updateTagsDisplay(container, tags || []);
} }
this.updateTagsDisplay(tagsContainer, tags || []); // Re-render tags display using shared utility, wire edit mode with ModelTags
updateTagsDisplay(container, tags) {
const filePath = this.filePath || '';
const tagsInput = tagsContainer.querySelector('.tags-input'); container.innerHTML = renderCompactTags(tags, filePath);
if (tagsInput) {
tagsInput.value = tags && tags.length > 0 ? tags.join(', ') : ''; // Setup tooltip for all tags
} setupTagTooltip(container);
// Wire edit button using shared tag editing (no suggestions for recipes)
setupTagEditMode(null, {
container: container,
showSuggestions: false,
normalizeTag: false,
saveHandler: async (filePath, tags) => {
await updateRecipeMetadata(filePath, { tags }, this.getMetadataUpdateOptions());
},
onSaved: (tags) => {
this.currentRecipe.tags = tags;
this.commitField('tags');
const c = document.getElementById('recipeTagsContainer');
if (c) this.updateTagsDisplay(c, tags);
},
});
} }
syncPromptField(field, value, placeholder) { syncPromptField(field, value, placeholder) {
@@ -664,10 +598,11 @@ class RecipeModal {
for (const [key, value] of Object.entries(sanitizedGenParams)) { for (const [key, value] of Object.entries(sanitizedGenParams)) {
if (!excludedParams.includes(key) && value !== undefined && value !== null) { if (!excludedParams.includes(key) && value !== undefined && value !== null) {
const displayName = PARAM_DISPLAY_NAMES[key] || key;
const paramTag = document.createElement('div'); const paramTag = document.createElement('div');
paramTag.className = 'param-tag'; paramTag.className = 'param-tag';
paramTag.innerHTML = ` paramTag.innerHTML = `
<span class="param-name">${key}:</span> <span class="param-name">${displayName}:</span>
<span class="param-value">${value}</span> <span class="param-value">${value}</span>
`; `;
otherParamsElement.appendChild(paramTag); otherParamsElement.appendChild(paramTag);
@@ -976,139 +911,6 @@ class RecipeModal {
} }
} }
// Tags editing methods
showTagsEditor() {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (tagsContainer) {
tagsContainer.querySelector('.editable-content').classList.add('hide');
const editor = tagsContainer.querySelector('#recipeTagsEditor');
editor.classList.add('active');
const input = editor.querySelector('input');
input.oninput = () => this.markFieldDirty('tags');
input.focus();
}
}
saveTagsEdit() {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (tagsContainer) {
const editor = tagsContainer.querySelector('#recipeTagsEditor');
const input = editor.querySelector('input');
const tagsText = input.value.trim();
// Parse tags
let newTags = [];
if (tagsText) {
newTags = tagsText.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
}
// Check if tags changed
const oldTags = this.currentRecipe.tags || [];
const tagsChanged =
newTags.length !== oldTags.length ||
newTags.some((tag, index) => tag !== oldTags[index]);
if (tagsChanged) {
// Update the recipe on the server
updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
.then(data => {
// Show success toast
showToast('toast.recipes.tagsUpdated', {}, 'success');
// Update the current recipe object
this.currentRecipe.tags = newTags;
this.commitField('tags');
// Update tags in the UI
this.updateTagsDisplay(tagsContainer, newTags);
})
.catch(error => {
// Error is handled in the API function
this.clearFieldDirty('tags');
});
} else {
this.clearFieldDirty('tags');
}
// Hide editor
editor.classList.remove('active');
tagsContainer.querySelector('.editable-content').classList.remove('hide');
}
}
// Helper method to update tags display
updateTagsDisplay(tagsContainer, tags) {
const tagsDisplay = tagsContainer.querySelector('.tags-display');
tagsDisplay.innerHTML = '';
if (tags.length > 0) {
// Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5;
const visibleTags = tags.slice(0, maxVisibleTags);
const remainingTags = tags.length > maxVisibleTags ? tags.slice(maxVisibleTags) : [];
// Add visible tags
visibleTags.forEach(tag => {
const tagElement = document.createElement('div');
tagElement.className = 'recipe-tag-compact';
tagElement.textContent = tag;
tagsDisplay.appendChild(tagElement);
});
// Add "more" button if needed
if (remainingTags.length > 0) {
const moreButton = document.createElement('div');
moreButton.className = 'recipe-tag-more';
moreButton.textContent = `+${remainingTags.length} more`;
tagsDisplay.appendChild(moreButton);
// Update tooltip content
const tooltipContent = document.getElementById('recipeTagsTooltipContent');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag;
tooltipContent.appendChild(tooltipTag);
});
}
// Re-add tooltip functionality
moreButton.addEventListener('mouseenter', () => {
document.getElementById('recipeTagsTooltip').classList.add('visible');
});
moreButton.addEventListener('mouseleave', () => {
setTimeout(() => {
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
}
}, 300);
});
}
} else {
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
}
}
cancelTagsEdit() {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (tagsContainer) {
// Reset input value
const editor = tagsContainer.querySelector('#recipeTagsEditor');
const input = editor.querySelector('input');
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
this.clearFieldDirty('tags');
// Hide editor
editor.classList.remove('active');
tagsContainer.querySelector('.editable-content').classList.remove('hide');
}
}
setupPromptEditors() { setupPromptEditors() {
const promptConfigs = [ const promptConfigs = [
{ {
@@ -1409,6 +1211,53 @@ class RecipeModal {
this.sendRecipeToWorkflow(); this.sendRecipeToWorkflow();
}); });
} }
// Send prompt to workflow buttons
const sendPromptBtn = document.getElementById('sendPromptBtn');
const sendNegativePromptBtn = document.getElementById('sendNegativePromptBtn');
if (sendPromptBtn) {
sendPromptBtn.addEventListener('click', () => {
let promptText = this.currentRecipe?.gen_params?.prompt || '';
if (this.shouldStripLoraOnCopy()) {
promptText = RecipeModal.stripLoraTags(promptText);
}
if (!promptText.trim()) {
showToast('toast.recipes.noPromptToSend', {}, 'warning');
return;
}
sendPromptToWorkflow(promptText);
});
}
if (sendNegativePromptBtn) {
sendNegativePromptBtn.addEventListener('click', () => {
let negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
if (this.shouldStripLoraOnCopy()) {
negativePromptText = RecipeModal.stripLoraTags(negativePromptText);
}
if (!negativePromptText.trim()) {
showToast('toast.recipes.noPromptToSend', {}, 'warning');
return;
}
sendPromptToWorkflow(negativePromptText, {
actionTypeText: 'Negative Prompt',
});
});
}
// Send params to workflow button
const sendParamsBtn = document.getElementById('sendParamsBtn');
if (sendParamsBtn) {
sendParamsBtn.addEventListener('click', () => {
const genParams = this.currentRecipe?.gen_params || {};
if (!genParams || Object.keys(genParams).length === 0) {
showToast('No generation parameters available', {}, 'warning');
return;
}
sendGenParamsToWorkflow(genParams);
});
}
} }
/** /**
@@ -1417,14 +1266,7 @@ class RecipeModal {
* Cleans up artifacts like leading ", ", double commas, and extra whitespace. * Cleans up artifacts like leading ", ", double commas, and extra whitespace.
*/ */
static stripLoraTags(text) { static stripLoraTags(text) {
return text return stripLoraTags(text);
.replace(/<lora:[^>]*>/gi, '')
.replace(/&lt;lora:[^&]*&gt;/gi, '')
.replace(/,(\s*,)+/g, ',')
.replace(/^,\s*/, '')
.replace(/,\s*$/, '')
.replace(/\s{2,}/g, ' ')
.trim();
} }
shouldStripLoraOnCopy() { shouldStripLoraOnCopy() {
+120 -16
View File
@@ -4,7 +4,7 @@
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js'; import { getModelApiClient } from '../api/modelApiFactory.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js'; import { bulkManager } from '../managers/BulkManager.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js'; import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
@@ -457,20 +457,68 @@ export class SidebarManager {
try { try {
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove); console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
let movedFiles = []; // Array of { original_file_path, new_file_path }
if (useBulkMove) { if (useBulkMove) {
await this.apiClient.moveBulkModels(this.draggedFilePaths, destination); const results = await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
movedFiles = (results || [])
.filter(r => r.success)
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
} else { } else {
await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination); const result = await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
if (result) {
movedFiles.push({
original_file_path: result.original_file_path || this.draggedFilePaths[0],
new_file_path: result.new_file_path
});
}
} }
console.log('[SidebarManager] apiClient.move successful'); console.log('[SidebarManager] apiClient.move successful');
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') { // Update VirtualScroller in-place instead of full reload
console.log('[SidebarManager] calling resetAndReload'); if (movedFiles.length > 0 && state.virtualScroller) {
await this.pageControls.resetAndReload(true); const pageState = getCurrentPageState();
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
const isRecursive = pageState.searchOptions?.recursive ?? true;
const isFolderFiltered = pageState.activeFolder !== null;
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
// Determine if items in the target folder are visible in the current view
let itemsRemainVisible = true;
if (isFolderFiltered) {
if (isRecursive) {
itemsRemainVisible = normalizedActive === '' ||
normalizedTarget === normalizedActive ||
normalizedTarget.startsWith(normalizedActive + '/');
} else { } else {
console.log('[SidebarManager] calling refresh'); itemsRemainVisible = normalizedTarget === normalizedActive;
await this.refresh();
} }
}
if (itemsRemainVisible) {
// Items stay visible — update each item's file_path to reflect new location
for (const moved of movedFiles) {
if (moved.original_file_path && moved.new_file_path) {
state.virtualScroller.updateSingleItem(moved.original_file_path, {
file_path: moved.new_file_path,
folder: normalizedTarget
});
}
}
} else {
// Items no longer visible in current folder — remove from VirtualScroller
const pathsToRemove = movedFiles
.map(m => m.original_file_path)
.filter(Boolean);
if (pathsToRemove.length > 0) {
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
}
}
}
// Refresh sidebar folder tree only (no model data reload)
await this.refresh();
if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') { if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
bulkManager.toggleBulkMode(); bulkManager.toggleBulkMode();
@@ -530,20 +578,68 @@ export class SidebarManager {
try { try {
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove); console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
let movedFiles = []; // Array of { original_file_path, new_file_path }
if (useBulkMove) { if (useBulkMove) {
await this.apiClient.moveBulkModels(draggedFilePaths, destination); const results = await this.apiClient.moveBulkModels(draggedFilePaths, destination);
movedFiles = (results || [])
.filter(r => r.success)
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
} else { } else {
await this.apiClient.moveSingleModel(draggedFilePaths[0], destination); const result = await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
if (result) {
movedFiles.push({
original_file_path: result.original_file_path || draggedFilePaths[0],
new_file_path: result.new_file_path
});
}
} }
console.log('[SidebarManager] apiClient.move successful'); console.log('[SidebarManager] apiClient.move successful');
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') { // Update VirtualScroller in-place instead of full reload
console.log('[SidebarManager] calling resetAndReload'); if (movedFiles.length > 0 && state.virtualScroller) {
await this.pageControls.resetAndReload(true); const pageState = getCurrentPageState();
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
const isRecursive = pageState.searchOptions?.recursive ?? true;
const isFolderFiltered = pageState.activeFolder !== null;
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
// Determine if items in the target folder are visible in the current view
let itemsRemainVisible = true;
if (isFolderFiltered) {
if (isRecursive) {
itemsRemainVisible = normalizedActive === '' ||
normalizedTarget === normalizedActive ||
normalizedTarget.startsWith(normalizedActive + '/');
} else { } else {
console.log('[SidebarManager] calling refresh'); itemsRemainVisible = normalizedTarget === normalizedActive;
await this.refresh();
} }
}
if (itemsRemainVisible) {
// Items stay visible — update each item's file_path to reflect new location
for (const moved of movedFiles) {
if (moved.original_file_path && moved.new_file_path) {
state.virtualScroller.updateSingleItem(moved.original_file_path, {
file_path: moved.new_file_path,
folder: normalizedTarget
});
}
}
} else {
// Items no longer visible in current folder — remove from VirtualScroller
const pathsToRemove = movedFiles
.map(m => m.original_file_path)
.filter(Boolean);
if (pathsToRemove.length > 0) {
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
}
}
}
// Refresh sidebar folder tree only (no model data reload)
await this.refresh();
if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') { if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
bulkManager.toggleBulkMode(); bulkManager.toggleBulkMode();
@@ -1040,7 +1136,15 @@ export class SidebarManager {
<span class="sidebar-hidden-indicator-tooltip">${translate('sidebar.showSidebar')}</span> <span class="sidebar-hidden-indicator-tooltip">${translate('sidebar.showSidebar')}</span>
`; `;
// Subtle breathing animation on first sight to aid discoverability;
// stops permanently after user clicks the restore button once
const restoreKey = `${this.pageType}_restoreButtonUsed`;
if (!getStorageItem(restoreKey, false)) {
indicator.classList.add('breathing');
}
indicator.addEventListener('click', () => { indicator.addEventListener('click', () => {
setStorageItem(restoreKey, true);
this.toggleHideOnThisPage(); this.toggleHideOnThisPage();
}); });
@@ -1338,7 +1442,7 @@ export class SidebarManager {
this.pageControls.pageState.activeFolder = normalizedPath; this.pageControls.pageState.activeFolder = normalizedPath;
setStorageItem(`${this.pageType}_activeFolder`, normalizedPath); setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
// Reload models with new filter // Reload models with new filter (loadMoreWithVirtualScroll will scroll to top)
await this.pageControls.resetAndReload(); await this.pageControls.resetAndReload();
} }
@@ -95,6 +95,23 @@ export class CheckpointsControls extends PageControls {
* Clear checkpoint custom filter and reload * Clear checkpoint custom filter and reload
*/ */
async clearCustomFilter() { async clearCustomFilter() {
// Check for View Local Versions filter first
const vlmModelId = getSessionItem('vlm_model_id');
if (vlmModelId) {
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
this._restoreSortAfterVlm();
// Hide the indicator
const indicator = document.getElementById('customFilterIndicator');
if (indicator) {
indicator.classList.add('hidden');
}
await resetAndReload();
return;
}
removeSessionItem('recipe_to_checkpoint_filterHash'); removeSessionItem('recipe_to_checkpoint_filterHash');
removeSessionItem('recipe_to_checkpoint_filterHashes'); removeSessionItem('recipe_to_checkpoint_filterHashes');
removeSessionItem('filterCheckpointRecipeName'); removeSessionItem('filterCheckpointRecipeName');
@@ -106,14 +123,4 @@ export class CheckpointsControls extends PageControls {
await resetAndReload(); await resetAndReload();
} }
/**
* Helper to truncate text with ellipsis
* @param {string} text
* @param {number} maxLength
* @returns {string}
*/
_truncateText(text, maxLength) {
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
}
} }
+16 -10
View File
@@ -112,6 +112,22 @@ export class LorasControls extends PageControls {
* Clear the custom filter and reload the page * Clear the custom filter and reload the page
*/ */
async clearCustomFilter() { async clearCustomFilter() {
// Check for View Local Versions filter first (handles VLM and reloads)
const vlmModelId = getSessionItem('vlm_model_id');
if (vlmModelId) {
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
this._restoreSortAfterVlm();
const indicator = document.getElementById('customFilterIndicator');
if (indicator) {
indicator.classList.add('hidden');
}
await resetAndReload();
return;
}
console.log("Clearing custom filter..."); console.log("Clearing custom filter...");
// Remove filter parameters from session storage // Remove filter parameters from session storage
removeSessionItem('recipe_to_lora_filterLoraHash'); removeSessionItem('recipe_to_lora_filterLoraHash');
@@ -134,16 +150,6 @@ export class LorasControls extends PageControls {
await resetAndReload(); await resetAndReload();
} }
/**
* Helper to truncate text with ellipsis
* @param {string} text - Text to truncate
* @param {number} maxLength - Maximum length before truncating
* @returns {string} - Truncated text
*/
_truncateText(text, maxLength) {
return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text;
}
/** /**
* Initialize the alphabet bar component * Initialize the alphabet bar component
*/ */
+221 -3
View File
@@ -1,9 +1,10 @@
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages // PageControls.js - Manages controls for both LoRAs and Checkpoints pages
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js'; import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js'; import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js'; import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js'; import { sidebarManager } from '../SidebarManager.js';
import { initSortDropdown } from './SortDropdown.js';
/** /**
* PageControls class - Unified control management for model pages * PageControls class - Unified control management for model pages
@@ -106,6 +107,7 @@ export class PageControls {
// Sort select handler // Sort select handler
const sortSelect = document.getElementById('sortSelect'); const sortSelect = document.getElementById('sortSelect');
if (sortSelect) { if (sortSelect) {
initSortDropdown(sortSelect);
sortSelect.value = this.pageState.sortBy; sortSelect.value = this.pageState.sortBy;
sortSelect.addEventListener('change', async (e) => { sortSelect.addEventListener('change', async (e) => {
this.pageState.sortBy = e.target.value; this.pageState.sortBy = e.target.value;
@@ -129,6 +131,9 @@ export class PageControls {
clearFilterBtn.addEventListener('click', () => this.clearCustomFilter()); clearFilterBtn.addEventListener('click', () => this.clearCustomFilter());
} }
// Check for View Local Versions filter
this.checkVlmFilter();
// Page-specific event listeners // Page-specific event listeners
this.initPageSpecificListeners(); this.initPageSpecificListeners();
} }
@@ -311,7 +316,12 @@ export class PageControls {
* Load sort preference from storage * Load sort preference from storage
*/ */
loadSortPreference() { loadSortPreference() {
const savedSort = getStorageItem(`${this.pageType}_sort`); // Use separate keys for grouped vs non-grouped sort so each mode
// remembers its own preference independently
const key = state.global.settings.group_by_model
? `${this.pageType}_sort_grouped`
: `${this.pageType}_sort`;
const savedSort = getStorageItem(key);
if (savedSort) { if (savedSort) {
// Handle legacy format conversion // Handle legacy format conversion
const convertedSort = this.convertLegacySortFormat(savedSort); const convertedSort = this.convertLegacySortFormat(savedSort);
@@ -355,7 +365,11 @@ export class PageControls {
}; };
return; return;
} }
setStorageItem(`${this.pageType}_sort`, sortValue); // Separate storage for grouped vs non-grouped sort
const key = state.global.settings.group_by_model
? `${this.pageType}_sort_grouped`
: `${this.pageType}_sort`;
setStorageItem(key, sortValue);
} }
/** /**
@@ -459,10 +473,206 @@ export class PageControls {
this.api.toggleBulkMode(); this.api.toggleBulkMode();
} }
/**
* Clear custom filter
*/
/**
* Dynamically add the VLM sort option (version_id:desc) to the sort dropdown.
* It is not a permanent option only present while VLM is active.
*/
_addVlmSortOption() {
const sortSelect = document.getElementById('sortSelect');
if (!sortSelect) return;
// Only add if not already present
if (sortSelect.querySelector('option[value="version_id:desc"]')) return;
const opt = document.createElement('option');
opt.value = 'version_id:desc';
opt.textContent = this._t('loras.controls.sort.versionIdDesc', 'Newest version first');
sortSelect.appendChild(opt);
}
/**
* Remove the VLM sort option from the sort dropdown.
*/
_removeVlmSortOption() {
const sortSelect = document.getElementById('sortSelect');
if (!sortSelect) return;
const opt = sortSelect.querySelector('option[value="version_id:desc"]');
if (opt) opt.remove();
}
/**
* Look up a translation key via the global i18n helper, falling back to
* a plain-text default when the key is missing or i18n is unavailable.
*/
_t(key, fallback) {
if (typeof window.i18n?.t === 'function') {
return window.i18n.t(key, { defaultValue: fallback });
}
return fallback;
}
/**
* Restore the sort dropdown state after VLM is cleared.
* Shared by PageControls.clearCustomFilter() and subclass overrides.
*/
_restoreSortAfterVlm() {
const prevSort = getSessionItem('vlm_prev_sort');
removeSessionItem('vlm_prev_sort');
const restoredSort = prevSort || 'name:asc';
this.pageState.sortBy = restoredSort;
this.saveSortPreference(restoredSort);
this._removeVlmSortOption();
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = restoredSort;
sortSelect.disabled = false;
}
}
/**
* Trigger View Local Versions without page reload
* Sets sessionStorage and reloads data via the API.
*/
triggerVlmView(modelId, modelName, baseModel, pageType) {
const targetPageType = pageType || this.pageType;
setSessionItem('vlm_model_id', String(modelId));
setSessionItem('vlm_model_name', modelName || String(modelId));
setSessionItem('vlm_page_type', targetPageType);
if (baseModel) {
setSessionItem('vlm_base_model', baseModel);
} else {
removeSessionItem('vlm_base_model');
}
// Save current sort preference so it can be restored when VLM is cleared
setSessionItem('vlm_prev_sort', this.pageState.sortBy);
// Inject the temporary sort option and force version_id:desc
this._addVlmSortOption();
this.pageState.sortBy = 'version_id:desc';
this.saveSortPreference('version_id:desc');
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = 'version_id:desc';
sortSelect.disabled = true;
}
// Reload data via API (no page reload)
this.resetAndReload(true).then(() => {
// Show the VLM indicator after data loads
this.checkVlmFilter();
});
}
/**
* Called when group_by_model is toggled.
* Swaps between {pageType}_sort (non-group) and {pageType}_sort_grouped,
* so each mode remembers its own sort preference independently.
*/
onGroupByModelToggled(isEnabled) {
const groupedKey = `${this.pageType}_sort_grouped`;
if (isEnabled) {
// Entering group mode: restore last-used grouped sort, if any
const savedGroupedSort = getStorageItem(groupedKey);
if (savedGroupedSort) {
this.pageState.sortBy = savedGroupedSort;
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = savedGroupedSort;
}
}
} else {
// Leaving group mode: persist current sort for next time, restore non-group sort
setStorageItem(groupedKey, this.pageState.sortBy);
const savedNormalSort = getStorageItem(`${this.pageType}_sort`);
if (savedNormalSort) {
this.pageState.sortBy = savedNormalSort;
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = savedNormalSort;
}
}
}
}
/**
* Check for View Local Versions filter in sessionStorage (page-type-scoped)
*/
checkVlmFilter() {
const vlmModelId = getSessionItem('vlm_model_id');
const vlmPageType = getSessionItem('vlm_page_type');
const sortSelect = document.getElementById('sortSelect');
// Only show VLM indicator when it belongs to the current page type
if (vlmModelId && vlmPageType !== this.pageType) {
// Stale VLM data from a different page — clean up
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
removeSessionItem('vlm_prev_sort');
this._removeVlmSortOption();
if (sortSelect) sortSelect.disabled = false;
return;
}
const vlmModelName = getSessionItem('vlm_model_name');
const vlmBaseModel = getSessionItem('vlm_base_model');
if (vlmModelId && vlmModelName) {
// VLM is active — inject sort option, disable dropdown, show indicator
this._addVlmSortOption();
if (sortSelect) {
sortSelect.value = 'version_id:desc';
sortSelect.disabled = true;
}
const indicator = document.getElementById('customFilterIndicator');
const filterText = indicator?.querySelector('.customFilterText');
if (indicator && filterText) {
indicator.classList.remove('hidden');
const prefix = vlmBaseModel
? 'Showing same-base versions from'
: 'Showing all versions from';
const displayText = `${prefix}: ${vlmModelName}`;
filterText.textContent = this._truncateText(displayText, 40);
filterText.setAttribute('title', displayText);
}
} else {
// No VLM — ensure sort option is removed and dropdown is enabled
this._removeVlmSortOption();
if (sortSelect) sortSelect.disabled = false;
}
}
/** /**
* Clear custom filter * Clear custom filter
*/ */
async clearCustomFilter() { async clearCustomFilter() {
// Check for View Local Versions filter first
const vlmModelId = getSessionItem('vlm_model_id');
if (vlmModelId) {
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
this._restoreSortAfterVlm();
// Hide the indicator
const indicator = document.getElementById('customFilterIndicator');
if (indicator) {
indicator.classList.add('hidden');
}
// Reload data via API (no page reload)
await this.resetAndReload(true);
return;
}
// Otherwise delegate to subclass for recipe filters
if (!this.api) { if (!this.api) {
console.error('API methods not registered'); console.error('API methods not registered');
return; return;
@@ -476,6 +686,14 @@ export class PageControls {
} }
} }
/**
* Truncate text with ellipsis
*/
_truncateText(text, maxLength) {
if (!text) return '';
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
}
/** /**
* Initialize the favorites filter button state * Initialize the favorites filter button state
*/ */
@@ -0,0 +1,294 @@
// SortDropdown.js — Decoupled sort trigger.
//
// The native <select> sizes its trigger to the widest <option>, so long
// options (e.g. "Fewest versions first") or long i18n translations force the
// control to be far wider than the selected text needs. This module wraps the
// existing <select> with a custom trigger + menu that mirror its state, so the
// trigger sizes to the selected text while the menu sizes to its content.
//
// The native <select> stays in the DOM (visually hidden) so existing code that
// reads/writes `.value` / `.disabled` and dynamically adds/removes <option>s
// (e.g. the VLM temporary option) keeps working unchanged. The `value` and
// `disabled` setters are overridden on the instance to keep the trigger label
// and disabled styling in sync with programmatic changes.
//
// Keyboard navigation (arrows, Home/End, type-to-select) mirrors native
// <select> behavior so the control remains fully accessible.
const SORT_GROUP_SELECTOR = '.sort-dropdown-group';
const ACTIVE_GROUP_SELECTOR = '.sort-dropdown-group.active, .dropdown-group.active';
/**
* Initialize a decoupled sort dropdown around a native <select>.
* Idempotent: safe to call more than once on the same element.
* @param {HTMLSelectElement|null} select
* @returns {void}
*/
export function initSortDropdown(select) {
if (!select) return;
const group = select.closest(SORT_GROUP_SELECTOR);
if (!group || group.dataset.sortReady === '1') return;
const trigger = group.querySelector('.sort-trigger');
const menu = group.querySelector('.sort-dropdown-menu');
const label = group.querySelector('.sort-trigger__label');
if (!trigger || !menu || !label) return;
const getOptions = () => menu.querySelectorAll('.sort-option');
const buildItem = (opt) => {
const item = document.createElement('div');
item.className = 'sort-option';
item.setAttribute('role', 'option');
item.tabIndex = -1;
item.dataset.value = opt.value;
item.textContent = opt.textContent;
item.addEventListener('click', (event) => {
event.stopPropagation();
if (select.disabled) return;
choose(opt.value);
close();
});
return item;
};
const buildMenu = () => {
menu.innerHTML = '';
const fragment = document.createDocumentFragment();
for (const child of Array.from(select.children)) {
if (child.tagName === 'OPTGROUP') {
const header = document.createElement('div');
header.className = 'sort-optgroup-label';
header.textContent = child.label || '';
fragment.appendChild(header);
for (const opt of Array.from(child.children)) {
fragment.appendChild(buildItem(opt));
}
} else if (child.tagName === 'OPTION') {
fragment.appendChild(buildItem(child));
}
}
menu.appendChild(fragment);
syncSelected();
};
const syncSelected = () => {
const value = select.value;
let labelText = '';
let matched = false;
getOptions().forEach((el) => {
const selected = el.dataset.value === value;
el.classList.toggle('is-selected', selected);
el.setAttribute('aria-selected', selected ? 'true' : 'false');
if (selected) {
labelText = el.textContent;
matched = true;
}
});
if (!matched) {
const opt = select.querySelector(`option[value="${cssEscape(value)}"]`);
labelText = opt
? opt.textContent
: (select.options[select.selectedIndex]?.textContent ?? '');
}
label.textContent = labelText;
};
const choose = (value) => {
if (select.value === value) return;
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true }));
};
const open = () => {
document.querySelectorAll(ACTIVE_GROUP_SELECTOR).forEach((g) => {
if (g !== group) g.classList.remove('active');
});
group.classList.add('active');
trigger.setAttribute('aria-expanded', 'true');
// Focus the currently selected option (or the first option) so
// keyboard navigation starts from a sensible position.
requestAnimationFrame(() => {
const selected = menu.querySelector('.sort-option.is-selected');
(selected || getOptions()[0])?.focus();
});
};
const close = () => {
group.classList.remove('active');
trigger.setAttribute('aria-expanded', 'false');
};
const toggle = () => {
if (group.classList.contains('active')) close();
else open();
};
// ---- keyboard navigation ----
// Type-to-select buffer: accumulate characters and reset after a pause.
// Shared between trigger and menu keydown handlers.
let typeBuffer = '';
let typeTimer = null;
const focusOptionByText = (prefix) => {
const options = getOptions();
const lower = prefix.toLowerCase();
for (let i = 0; i < options.length; i++) {
if (options[i].textContent.toLowerCase().startsWith(lower)) {
options[i].focus();
return;
}
}
};
const moveFocus = (options, direction) => {
const focused = menu.querySelector('.sort-option:focus');
let idx = focused ? Array.from(options).indexOf(focused) : -1;
idx = Math.max(0, Math.min(options.length - 1, idx + direction));
options[idx]?.focus();
};
const handleTypeToSelect = (event) => {
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return false;
event.preventDefault();
clearTimeout(typeTimer);
typeBuffer += event.key;
focusOptionByText(typeBuffer);
typeTimer = setTimeout(() => { typeBuffer = ''; }, 800);
return true;
};
trigger.addEventListener('click', (event) => {
event.stopPropagation();
if (select.disabled) return;
toggle();
});
trigger.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
close();
} else if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
event.preventDefault();
if (!select.disabled) toggle();
} else if (!group.classList.contains('active')) {
// Type-to-select on closed dropdown: open and highlight match
if (handleTypeToSelect(event)) {
open();
}
}
});
menu.addEventListener('keydown', (event) => {
const options = getOptions();
if (options.length === 0) return;
switch (event.key) {
case 'Escape':
event.preventDefault();
close();
trigger.focus();
return;
case 'ArrowDown':
event.preventDefault();
moveFocus(options, 1);
return;
case 'ArrowUp':
event.preventDefault();
moveFocus(options, -1);
return;
case 'Home':
event.preventDefault();
options[0]?.focus();
return;
case 'End':
event.preventDefault();
options[options.length - 1]?.focus();
return;
case 'Enter':
case ' ':
event.preventDefault();
if (select.disabled) return;
const focused = menu.querySelector('.sort-option:focus');
if (focused) {
choose(focused.dataset.value);
close();
trigger.focus();
}
return;
}
handleTypeToSelect(event);
});
// Close dropdown when clicking outside
document.addEventListener('click', (event) => {
if (!group.contains(event.target)) {
const wasOpen = group.classList.contains('active');
close();
// Only return focus to the trigger when the dropdown was actually
// open — avoids forcing scrollIntoView on every page click (which
// causes the scroll container to jump when clicking a model card).
if (wasOpen) trigger.focus();
}
});
// ---- property overrides ----
// Override `value` and `disabled` on this instance so programmatic
// changes (loadSortPreference, VLM toggle, excluded-view sync, ...) keep
// the trigger label and disabled styling in sync without touching callers.
const proto = Object.getPrototypeOf(select);
const valueDescriptor =
Object.getOwnPropertyDescriptor(proto, 'value') ||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
const disabledDescriptor =
Object.getOwnPropertyDescriptor(proto, 'disabled') ||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'disabled');
if (valueDescriptor) {
Object.defineProperty(select, 'value', {
get() { return valueDescriptor.get.call(this); },
set(v) {
valueDescriptor.set.call(this, v);
syncSelected();
},
configurable: true,
});
}
if (disabledDescriptor) {
Object.defineProperty(select, 'disabled', {
get() { return disabledDescriptor.get.call(this); },
set(v) {
disabledDescriptor.set.call(this, v);
group.classList.toggle('is-disabled', Boolean(v));
trigger.disabled = Boolean(v);
if (v) close();
},
configurable: true,
});
}
// Rebuild the menu when <option>s change (VLM adds/removes a temporary
// option at runtime).
const observer = new MutationObserver(() => buildMenu());
observer.observe(select, { childList: true });
buildMenu();
group.dataset.sortReady = '1';
}
function cssEscape(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value);
}
// Fallback for environments without CSS.escape
return String(value).replace(/[!"#$%&'()*+,./:;<=>?@[\]^`{|}~\\ -]/g, '\\$&');
}
+52 -6
View File
@@ -1,4 +1,4 @@
import { showToast, openCivitai, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js'; import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../../state/index.js'; import { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js'; import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js'; import { toggleShowcase } from './showcase/ShowcaseView.js';
@@ -66,6 +66,8 @@ function handleModelCardEvent_internal(event, modelType) {
event.stopPropagation(); event.stopPropagation();
if (card.dataset.from_civitai === 'true') { if (card.dataset.from_civitai === 'true') {
openCivitai(card.dataset.filepath); openCivitai(card.dataset.filepath);
} else if (card.dataset.hf_url) {
openHuggingFace(card.dataset.hf_url);
} }
return true; // Stop propagation return true; // Stop propagation
} }
@@ -100,6 +102,12 @@ function handleModelCardEvent_internal(event, modelType) {
return true; // Stop propagation return true; // Stop propagation
} }
if (event.target.closest('.version-count-link')) {
event.stopPropagation();
handleViewLocalVersionsFromCard(card, modelType);
return true;
}
// If no specific element was clicked, handle the card click (show modal or toggle selection) // If no specific element was clicked, handle the card click (show modal or toggle selection)
handleCardClick(card, modelType); handleCardClick(card, modelType);
return false; // Continue with other handlers (e.g., bulk selection) return false; // Continue with other handlers (e.g., bulk selection)
@@ -265,6 +273,22 @@ async function handleExampleImagesAccess(card, modelType) {
} }
} }
function handleViewLocalVersionsFromCard(card, modelType) {
const modelId = card.dataset.modelId;
const modelName = card.dataset.name;
if (!modelId) return;
// Respect version_grouping: only filter by base model when the strategy says so
const strategy = state.global?.settings?.version_grouping;
const shouldFilterByBase = strategy === 'same_base';
const baseModel = shouldFilterByBase && card.dataset.base_model !== 'Unknown'
? card.dataset.base_model
: undefined;
// Use the no-reload VLM flow via PageControls
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
window.pageControls.triggerVlmView(modelId, modelName, baseModel, modelType);
}
}
function handleCardClick(card, modelType) { function handleCardClick(card, modelType) {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
@@ -291,6 +315,7 @@ async function showModelModalFromCard(card, modelType) {
modified: card.dataset.modified, modified: card.dataset.modified,
file_size: parseInt(card.dataset.file_size || '0'), file_size: parseInt(card.dataset.file_size || '0'),
from_civitai: card.dataset.from_civitai === 'true', from_civitai: card.dataset.from_civitai === 'true',
hf_url: card.dataset.hf_url || '',
base_model: card.dataset.base_model, base_model: card.dataset.base_model,
notes: card.dataset.notes || '', notes: card.dataset.notes || '',
favorite: card.dataset.favorite === 'true', favorite: card.dataset.favorite === 'true',
@@ -379,6 +404,7 @@ function showExampleAccessModal(card, modelType) {
modified: card.dataset.modified, modified: card.dataset.modified,
file_size: card.dataset.file_size, file_size: card.dataset.file_size,
from_civitai: card.dataset.from_civitai === 'true', from_civitai: card.dataset.from_civitai === 'true',
hf_url: card.dataset.hf_url || '',
base_model: card.dataset.base_model, base_model: card.dataset.base_model,
notes: card.dataset.notes, notes: card.dataset.notes,
favorite: card.dataset.favorite === 'true', favorite: card.dataset.favorite === 'true',
@@ -445,9 +471,14 @@ export function createModelCard(model, modelType) {
card.dataset.base_model = model.base_model || 'Unknown'; card.dataset.base_model = model.base_model || 'Unknown';
card.dataset.favorite = model.favorite ? 'true' : 'false'; card.dataset.favorite = model.favorite ? 'true' : 'false';
card.dataset.exclude = model.exclude ? 'true' : 'false'; card.dataset.exclude = model.exclude ? 'true' : 'false';
card.dataset.hf_url = model.hf_url || '';
const hasUpdateAvailable = Boolean(model.update_available); const hasUpdateAvailable = Boolean(model.update_available);
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false'; card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false'; card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
// Store version_count for group-by-model display
if (model.version_count !== undefined) {
card.dataset.version_count = model.version_count;
}
// To only show usage_count when sorting by usage. // To only show usage_count when sorting by usage.
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
@@ -552,7 +583,10 @@ export function createModelCard(model, modelType) {
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites'); translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
const globeTitle = model.from_civitai ? const globeTitle = model.from_civitai ?
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') : translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
model.hf_url ?
translate('modelCard.actions.viewOnHuggingFace', {}, 'View on Hugging Face') :
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai'); translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
const globeEnabled = model.from_civitai || !!model.hf_url;
let sendTitle; let sendTitle;
let copyTitle; let copyTitle;
if (modelType === MODEL_TYPES.LORA) { if (modelType === MODEL_TYPES.LORA) {
@@ -577,7 +611,7 @@ export function createModelCard(model, modelType) {
</i> </i>
<i class="fas fa-globe" <i class="fas fa-globe"
title="${globeTitle}" title="${globeTitle}"
${!model.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}> ${!globeEnabled ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
</i> </i>
<i class="fas fa-paper-plane" <i class="fas fa-paper-plane"
title="${sendTitle}"> title="${sendTitle}">
@@ -659,16 +693,28 @@ export function createModelCard(model, modelType) {
const autoTags = model.auto_tags || []; const autoTags = model.auto_tags || [];
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW'); const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
const hasVersionName = model.civitai?.name; const hasVersionName = model.civitai?.name;
if (!hlTags.length && !hasVersionName) return ''; // When group_by_model is active and model has multiple versions,
// show clickable version count instead of version name (and hide badges)
const isGroupByModel = state.global.settings.group_by_model;
const versionCount = model.version_count;
const showVersionCount = isGroupByModel && versionCount > 1;
if (!hlTags.length && !hasVersionName && !showVersionCount) return '';
const density = state.global.settings.display_density || 'default'; const density = state.global.settings.display_density || 'default';
const shortLabels = density === 'medium' || density === 'compact'; const shortLabels = density === 'medium' || density === 'compact';
const badges = hlTags.map(t => { // Don't show HIGH/LOW badges when showing version count (confusing in grouped mode)
const badges = !showVersionCount ? hlTags.map(t => {
const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low'; const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t; const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
const titleAttr = shortLabels ? ` title="${t}"` : ''; const titleAttr = shortLabels ? ` title="${t}"` : '';
return `<span class="${cls}"${titleAttr}>${label}</span>`; return `<span class="${cls}"${titleAttr}>${label}</span>`;
}).join(''); }).join('') : '';
const versionHtml = hasVersionName ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : ''; let versionHtml = '';
if (showVersionCount) {
const countLabel = translate('modelCard.footer.versionCount', { count: versionCount }, `${versionCount} versions`);
versionHtml = `<span class="version-count-link" title="${translate('modelCard.footer.viewAllVersions', {}, 'View all local versions')}">${countLabel}</span>`;
} else if (hasVersionName) {
versionHtml = `<span class="version-name civitai-version">${model.civitai.name}</span>`;
}
return `<span class="badge-version-unit">${badges}${versionHtml}</span>`; return `<span class="badge-version-unit">${badges}${versionHtml}</span>`;
})()} })()}
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''} ${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
+292 -71
View File
@@ -3,9 +3,75 @@
* Handles model metadata editing functionality - General version * Handles model metadata editing functionality - General version
*/ */
import { BASE_MODEL_CATEGORIES } from '../../utils/constants.js'; import { BASE_MODEL_CATEGORIES, getMergedBaseModels } from '../../utils/constants.js';
import { showToast } from '../../utils/uiHelpers.js'; import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js'; import { getModelApiClient } from '../../api/modelApiFactory.js';
import { translate } from '../../utils/i18nHelpers.js';
// ── Filename-based base model inference ──────────────────────────────────────
// Rules are ordered by specificity — first match wins for dedup.
// Each rule checks the filename (lowercased) for a regex pattern and suggests
// the associated base model values.
const BASE_MODEL_FILENAME_RULES = [
{ pattern: /flux\.?\s*2\s*klein/i, models: ['Flux.2 Klein 9B', 'Flux.2 Klein 9B-base', 'Flux.2 Klein 4B', 'Flux.2 Klein 4B-base'] },
{ pattern: /flux\.?\s*2/i, models: ['Flux.2 D', 'Flux.2 Klein 9B', 'Flux.2 Klein 4B'] },
{ pattern: /flux\.?\s*1\s*(dev|d)\b/i, models: ['Flux.1 D'] },
{ pattern: /flux\.?\s*1\s*(schnell|s)\b/i, models: ['Flux.1 S'] },
{ pattern: /flux/i, models: ['Flux.1 D', 'Flux.1 S', 'Flux.2 D'] },
{ pattern: /sdxl/i, models: ['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper'] },
{ pattern: /sd\s*1[._-\s]?5/i, models: ['SD 1.5'] },
{ pattern: /sd\s*1[._-\s]?4/i, models: ['SD 1.4'] },
{ pattern: /sd\s*1/i, models: ['SD 1.5', 'SD 1.4', 'SD 1.5 LCM', 'SD 1.5 Hyper'] },
{ pattern: /sd\s*3[._-\s]?5/i, models: ['SD 3.5', 'SD 3.5 Medium', 'SD 3.5 Large', 'SD 3.5 Large Turbo'] },
{ pattern: /sd\s*3/i, models: ['SD 3', 'SD 3.5'] },
{ pattern: /wan\s*\.?\s*video/i, models: ['Wan Video', 'Wan Video 1.3B t2v', 'Wan Video 14B t2v', 'Wan Video 14B i2v 480p', 'Wan Video 14B i2v 720p'] },
{ pattern: /hunyuan\s*\.?\s*video/i, models: ['Hunyuan Video'] },
{ pattern: /ltxv/i, models: ['LTXV', 'LTXV2', 'LTXV 2.3'] },
{ pattern: /cogvideo/i, models: ['CogVideoX'] },
{ pattern: /pony/i, models: ['Pony', 'Pony V7'] },
{ pattern: /illustrious/i, models: ['Illustrious'] },
{ pattern: /noobai/i, models: ['NoobAI'] },
{ pattern: /pixart/i, models: ['PixArt a', 'PixArt E'] },
{ pattern: /aura\s*\.?\s*flow/i, models: ['AuraFlow'] },
{ pattern: /kolors/i, models: ['Kolors'] },
{ pattern: /hunyuan\s*1/i, models: ['Hunyuan 1'] },
{ pattern: /lumina/i, models: ['Lumina'] },
{ pattern: /hidream/i, models: ['HiDream'] },
{ pattern: /qwen/i, models: ['Qwen'] },
{ pattern: /chroma/i, models: ['Chroma'] },
{ pattern: /anima/i, models: ['Anima'] },
{ pattern: /sd\s*2[._-\s]?[01]/i, models: ['SD 2.0', 'SD 2.1'] },
{ pattern: /mochi/i, models: ['Mochi'] },
{ pattern: /svd/i, models: ['SVD'] },
{ pattern: /zimage/i, models: ['ZImageTurbo', 'ZImageBase'] },
{ pattern: /nucleus/i, models: ['Nucleus'] },
{ pattern: /krea/i, models: ['Flux.1 Krea', 'Krea 2'] },
{ pattern: /ernie/i, models: ['Ernie', 'Ernie Turbo'] },
];
/**
* Infer likely base model(s) from a filename + model name string.
* Returns a deduplicated array in match-priority order.
* @param {string} filename
* @returns {string[]}
*/
function inferBaseModelsFromFilename(filename) {
if (!filename || typeof filename !== 'string') return [];
const seen = new Set();
const results = [];
for (const rule of BASE_MODEL_FILENAME_RULES) {
if (rule.pattern.test(filename)) {
for (const model of rule.models) {
if (!seen.has(model)) {
seen.add(model);
results.push(model);
}
}
}
}
return results;
}
/** /**
* Resolve the active file path for the currently open model modal. * Resolve the active file path for the currently open model modal.
@@ -226,7 +292,9 @@ export function setupModelNameEditing(filePath) {
} }
/** /**
* Set up base model editing functionality * Set up base model editing functionality with searchable dropdown
* Shows filename-inferred suggestions at the top, supports keyboard navigation,
* and allows typing custom values.
* @param {string} filePath - File path * @param {string} filePath - File path
*/ */
export function setupBaseModelEditing(filePath) { export function setupBaseModelEditing(filePath) {
@@ -257,98 +325,251 @@ export function setupBaseModelEditing(filePath) {
// Store the original value to check for changes later // Store the original value to check for changes later
const originalValue = baseModelContent.textContent.trim(); const originalValue = baseModelContent.textContent.trim();
// Create dropdown selector to replace the base model content // ── Build the full option list ────────────────────────────────────────
const currentValue = originalValue; const allModels = []; // { value, label, category }
const dropdown = document.createElement('select'); const categorizedModels = new Set();
dropdown.className = 'base-model-selector';
// Flag to track if a change was made
let valueChanged = false;
// Add options from BASE_MODEL_CATEGORIES constants
const baseModelCategories = BASE_MODEL_CATEGORIES;
// Create option groups for better organization
Object.entries(baseModelCategories).forEach(([category, models]) => {
const group = document.createElement('optgroup');
group.label = category;
Object.entries(BASE_MODEL_CATEGORIES).forEach(([category, models]) => {
models.forEach(model => { models.forEach(model => {
const option = document.createElement('option'); allModels.push({ value: model, label: model, category });
option.value = model; categorizedModels.add(model);
option.textContent = model; });
option.selected = model === currentValue;
group.appendChild(option);
}); });
dropdown.appendChild(group); const mergedModels = getMergedBaseModels();
const uncategorizedModels = mergedModels.filter(model => !categorizedModels.has(model));
if (uncategorizedModels.length > 0) {
uncategorizedModels.forEach(model => {
allModels.push({ value: model, label: model, category: 'Other (API)' });
});
}
// ── Filename-based inference ──────────────────────────────────────────
const fileName = (document.querySelector('.file-name-content')?.textContent || '') + ' ' +
(document.querySelector('.model-name-content')?.textContent || '');
const inferredModels = inferBaseModelsFromFilename(fileName);
const inferredSet = new Set(inferredModels);
// ── Build search widget DOM ───────────────────────────────────────────
const wrapper = document.createElement('div');
wrapper.className = 'base-model-search-wrapper';
// Search input row
const inputWrapper = document.createElement('div');
inputWrapper.className = 'base-model-search-input-wrapper';
const searchIcon = document.createElement('i');
searchIcon.className = 'fas fa-search search-icon';
searchIcon.setAttribute('aria-hidden', 'true');
inputWrapper.appendChild(searchIcon);
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'base-model-search-input';
searchInput.placeholder = translate('modals.model.metadata.baseModelSearchPlaceholder', {}, 'Search base model…');
searchInput.autocomplete = 'off';
searchInput.spellcheck = false;
inputWrapper.appendChild(searchInput);
wrapper.appendChild(inputWrapper);
// Dropdown list
const dropdown = document.createElement('div');
dropdown.className = 'base-model-dropdown';
wrapper.appendChild(dropdown);
// ── Render ────────────────────────────────────────────────────────────
function renderDropdown(filterText) {
const lowerFilter = (filterText || '').toLowerCase().trim();
dropdown.innerHTML = '';
let hasVisibleItems = false;
const fragment = document.createDocumentFragment();
// 1. Suggested section (filename-inferred, filtered by search)
let suggestedToShow = inferredModels;
if (lowerFilter) {
suggestedToShow = inferredModels.filter(m =>
m.toLowerCase().includes(lowerFilter)
);
}
if (suggestedToShow.length > 0) {
const section = document.createElement('div');
section.className = 'base-model-dropdown-section';
const header = document.createElement('div');
header.className = 'base-model-dropdown-header suggested-header';
header.innerHTML = '<i class="fas fa-star" aria-hidden="true"></i> ' +
translate('modals.model.metadata.baseModelSuggested', {}, 'Suggested');
section.appendChild(header);
suggestedToShow.forEach(model => {
const item = document.createElement('div');
item.className = 'base-model-dropdown-item';
if (model === originalValue) item.classList.add('selected');
item.dataset.value = model;
item.textContent = model;
section.appendChild(item);
hasVisibleItems = true;
}); });
// Replace content with dropdown fragment.appendChild(section);
}
// 2. Categorized options (deduplicated against suggestions)
const categoryMap = {};
allModels.forEach(m => {
if (inferredSet.has(m.value)) return; // already shown in Suggested
if (lowerFilter && !m.label.toLowerCase().includes(lowerFilter)) return;
if (!categoryMap[m.category]) categoryMap[m.category] = [];
categoryMap[m.category].push(m);
});
Object.entries(categoryMap).forEach(([category, items]) => {
if (items.length === 0) return;
const section = document.createElement('div');
section.className = 'base-model-dropdown-section';
const header = document.createElement('div');
header.className = 'base-model-dropdown-header';
header.textContent = category;
section.appendChild(header);
items.forEach(m => {
const item = document.createElement('div');
item.className = 'base-model-dropdown-item';
if (m.value === originalValue) item.classList.add('selected');
item.dataset.value = m.value;
item.textContent = m.label;
section.appendChild(item);
hasVisibleItems = true;
});
fragment.appendChild(section);
});
// 3. Empty state
if (!hasVisibleItems) {
const empty = document.createElement('div');
empty.className = 'base-model-dropdown-empty';
empty.textContent = translate('modals.model.metadata.baseModelNoMatch', {}, 'No matching base models');
fragment.appendChild(empty);
}
dropdown.appendChild(fragment);
// Scroll the selected item into view
const selected = dropdown.querySelector('.base-model-dropdown-item.selected');
if (selected) {
selected.scrollIntoView({ block: 'nearest' });
}
}
// Initial render — show everything
renderDropdown('');
// ── Events ────────────────────────────────────────────────────────────
let filterTimeout;
searchInput.addEventListener('input', () => {
clearTimeout(filterTimeout);
filterTimeout = setTimeout(() => renderDropdown(searchInput.value), 50);
});
// Click to select
dropdown.addEventListener('click', (e) => {
const item = e.target.closest('.base-model-dropdown-item');
if (!item) return;
baseModelContent.textContent = item.dataset.value;
cleanup();
const finalValue = baseModelContent.textContent.trim();
if (finalValue !== originalValue) {
saveBaseModel(
getActiveModalFilePath(baseModelContent.dataset.filePath),
originalValue
);
}
});
// Replace content with search widget
baseModelContent.style.display = 'none'; baseModelContent.style.display = 'none';
baseModelDisplay.insertBefore(dropdown, editBtn);
// Hide edit button during editing
editBtn.style.display = 'none'; editBtn.style.display = 'none';
baseModelDisplay.insertBefore(wrapper, editBtn);
searchInput.focus();
// Focus the dropdown // ── Cleanup ───────────────────────────────────────────────────────────
dropdown.focus(); function cleanup() {
if (wrapper.parentNode === baseModelDisplay) {
// Handle dropdown change baseModelDisplay.removeChild(wrapper);
dropdown.addEventListener('change', function() {
const selectedModel = this.value;
baseModelContent.textContent = selectedModel;
// Mark that a change was made if the value differs from original
if (selectedModel !== originalValue) {
valueChanged = true;
} else {
valueChanged = false;
} }
});
// Function to save changes and exit edit mode
const saveAndExit = function() {
// Check if dropdown still exists and remove it
if (dropdown && dropdown.parentNode === baseModelDisplay) {
baseModelDisplay.removeChild(dropdown);
}
// Show the content and edit button
baseModelContent.style.display = ''; baseModelContent.style.display = '';
editBtn.style.display = ''; editBtn.style.display = '';
// Remove editing class
baseModelDisplay.classList.remove('editing'); baseModelDisplay.classList.remove('editing');
// Only save if the value has actually changed
if (valueChanged || baseModelContent.textContent.trim() !== originalValue) {
const resolvedPath = getActiveModalFilePath(baseModelContent.dataset.filePath);
saveBaseModel(resolvedPath, originalValue);
}
// Remove this event listener
document.removeEventListener('click', outsideClickHandler); document.removeEventListener('click', outsideClickHandler);
}; }
// Handle outside clicks to save and exit // Outside click save typed/custom value if any
const outsideClickHandler = function(e) { const outsideClickHandler = function(e) {
// If click is outside the dropdown and base model display if (wrapper.contains(e.target)) return;
if (!baseModelDisplay.contains(e.target)) {
saveAndExit(); // If user typed a custom value (not just empty), apply it
const typedValue = searchInput.value.trim();
if (typedValue) {
baseModelContent.textContent = typedValue;
}
cleanup();
const finalValue = baseModelContent.textContent.trim();
if (finalValue !== originalValue) {
saveBaseModel(
getActiveModalFilePath(baseModelContent.dataset.filePath),
originalValue
);
} }
}; };
// Add delayed event listener for outside clicks // Defer listener to avoid the opening click itself
setTimeout(() => { setTimeout(() => {
document.addEventListener('click', outsideClickHandler); document.addEventListener('click', outsideClickHandler);
}, 0); }, 0);
// Also handle dropdown blur event // Keyboard navigation
dropdown.addEventListener('blur', function(e) { searchInput.addEventListener('keydown', function onKeydown(e) {
// Only save if the related target is not the edit button or inside the baseModelDisplay const items = Array.from(dropdown.querySelectorAll('.base-model-dropdown-item'));
if (!baseModelDisplay.contains(e.relatedTarget)) { const activeIdx = items.findIndex(el => el.classList.contains('active'));
saveAndExit();
if (e.key === 'ArrowDown') {
e.preventDefault();
items.forEach(el => el.classList.remove('active'));
const next = Math.min(activeIdx + 1, items.length - 1);
if (items[next]) {
items[next].classList.add('active');
items[next].scrollIntoView({ block: 'nearest' });
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
items.forEach(el => el.classList.remove('active'));
const prev = Math.max(activeIdx - 1, 0);
if (items[prev]) {
items[prev].classList.add('active');
items[prev].scrollIntoView({ block: 'nearest' });
}
} else if (e.key === 'Enter') {
e.preventDefault();
const activeItem = items.find(el => el.classList.contains('active'));
if (activeItem) {
activeItem.click();
} else if (searchInput.value.trim()) {
// Custom value typed
baseModelContent.textContent = searchInput.value.trim();
cleanup();
const finalValue = baseModelContent.textContent.trim();
if (finalValue !== originalValue) {
saveBaseModel(
getActiveModalFilePath(baseModelContent.dataset.filePath),
originalValue
);
}
}
} else if (e.key === 'Escape') {
e.preventDefault();
baseModelContent.textContent = originalValue;
cleanup();
} }
}); });
}); });
+107 -1
View File
@@ -234,6 +234,95 @@ function renderLicenseIcons(modelData) {
</div>`; </div>`;
} }
// ── Set 2 (new CivitAI-style) permission icons ──
const NEW_LICENSE_ICON_CONFIG = [
{
key: 'commercial',
icon: 'currency-dollar.svg',
allowedFn: (license) => {
const uses = license.allowCommercialUse || [];
return uses.includes('Image') || uses.includes('Sell');
},
labelAllowed: 'Commercial use allowed',
labelDenied: 'No commercial use'
},
{
key: 'genServices',
icon: 'brush.svg',
allowedFn: (license) => {
const uses = license.allowCommercialUse || [];
return uses.includes('RentCivit') || uses.includes('Rent');
},
labelAllowed: 'Generation services allowed',
labelDenied: 'No generation services'
},
{
key: 'credit',
icon: 'user.svg',
allowedFn: (license) => !!license.allowNoCredit,
labelAllowed: 'No credit required',
labelDenied: 'Creator credit required'
},
{
key: 'derivatives',
icon: 'git-merge.svg',
allowedFn: (license) => !!license.allowDerivatives,
labelAllowed: 'Merges allowed',
labelDenied: 'No merges allowed'
},
{
key: 'relicense',
icon: 'license.svg',
allowedFn: (license) => !!license.allowDifferentLicense,
labelAllowed: 'Different permissions allowed on merges',
labelDenied: 'Same permissions required on merges'
}
];
function createNewLicenseIconMarkup(icon, allowed, label) {
const safeLabel = escapeAttribute(label);
const iconPath = `/loras_static/images/tabler/${icon}`;
const stateClass = allowed ? 'allowed' : 'denied';
return `<span class="license-icon-new ${stateClass}" role="img" aria-label="${safeLabel}" title="${safeLabel}" style="--license-icon-image: url('${iconPath}')"></span>`;
}
function renderNewLicenseIcons(modelData) {
const license = modelData?.civitai?.model;
if (!license) {
return '';
}
const icons = [];
NEW_LICENSE_ICON_CONFIG.forEach((config) => {
if (config.key === 'credit' && !hasLicenseField(license, 'allowNoCredit')) {
return;
}
if (config.key === 'derivatives' && !hasLicenseField(license, 'allowDerivatives')) {
return;
}
if (config.key === 'relicense' && !hasLicenseField(license, 'allowDifferentLicense')) {
return;
}
if ((config.key === 'commercial' || config.key === 'genServices') && !hasLicenseField(license, 'allowCommercialUse')) {
return;
}
const allowed = config.allowedFn(license);
const label = allowed ? config.labelAllowed : config.labelDenied;
icons.push(createNewLicenseIconMarkup(config.icon, allowed, label));
});
if (!icons.length) {
return '';
}
const containerLabel = translate('modals.model.license.restrictionsLabel', {}, 'License permissions');
const safeContainerLabel = escapeAttribute(containerLabel);
return `<div class="license-permissions" aria-label="${safeContainerLabel}" role="group">
${icons.join('\n ')}
</div>`;
}
/** /**
* Display the model modal with the given model data * Display the model modal with the given model data
* @param {Object} model - Model data object * @param {Object} model - Model data object
@@ -264,10 +353,18 @@ export async function showModelModal(model, modelType) {
}; };
const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || ''); const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || '');
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A'); const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A');
const licenseIcons = renderLicenseIcons(modelWithFullData); const useNewIcons = state.global.settings.use_new_license_icons !== false;
const licenseIcons = useNewIcons
? renderNewLicenseIcons(modelWithFullData)
: renderLicenseIcons(modelWithFullData);
const viewOnCivitaiAction = modelWithFullData.from_civitai ? ` const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}"> <div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')} <i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
</div>`.trim() : '';
const escapedHfUrl = modelWithFullData.hf_url ? escapeAttribute(modelWithFullData.hf_url) : '';
const viewOnHuggingFaceAction = escapedHfUrl ? `
<div class="civitai-view" title="${translate('modals.model.actions.viewOnHuggingFace', {}, 'View on Hugging Face')}" data-action="view-huggingface" data-hf-url="${escapedHfUrl}">
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnHuggingFaceText', {}, 'View on Hugging Face')}
</div>`.trim() : ''; </div>`.trim() : '';
const creatorInfoAction = modelWithFullData.civitai?.creator ? ` const creatorInfoAction = modelWithFullData.civitai?.creator ? `
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}"> <div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
@@ -285,6 +382,9 @@ export async function showModelModal(model, modelType) {
if (viewOnCivitaiAction) { if (viewOnCivitaiAction) {
creatorActionItems.push(indentMarkup(viewOnCivitaiAction, 24)); creatorActionItems.push(indentMarkup(viewOnCivitaiAction, 24));
} }
if (viewOnHuggingFaceAction) {
creatorActionItems.push(indentMarkup(viewOnHuggingFaceAction, 24));
}
if (creatorInfoAction) { if (creatorInfoAction) {
creatorActionItems.push(indentMarkup(creatorInfoAction, 24)); creatorActionItems.push(indentMarkup(creatorInfoAction, 24));
} }
@@ -660,6 +760,7 @@ export async function showModelModal(model, modelType) {
modelId: civitaiModelId, modelId: civitaiModelId,
currentVersionId: civitaiVersionId, currentVersionId: civitaiVersionId,
currentBaseModel: modelWithFullData.base_model, currentBaseModel: modelWithFullData.base_model,
modelName: model.model_name,
onUpdateStatusChange: handleUpdateStatusChange, onUpdateStatusChange: handleUpdateStatusChange,
}); });
setupEditableFields(modelWithFullData.file_path, modelType); setupEditableFields(modelWithFullData.file_path, modelType);
@@ -776,6 +877,11 @@ function setupEventHandlers(filePath, modelType) {
case 'view-civitai': case 'view-civitai':
openCivitai(target.dataset.filepath); openCivitai(target.dataset.filepath);
break; break;
case 'view-huggingface':
if (target.dataset.hfUrl) {
window.open(target.dataset.hfUrl, '_blank', 'noopener,noreferrer');
}
break;
case 'view-creator': case 'view-creator':
const username = target.dataset.username; const username = target.dataset.username;
if (username) { if (username) {
+79 -25
View File
@@ -29,6 +29,14 @@ let priorityTagSuggestionsLoaded = false;
let priorityTagSuggestionsPromise = null; let priorityTagSuggestionsPromise = null;
let activeTagDragState = null; let activeTagDragState = null;
// Configurable options for tag editing (set by setupTagEditMode)
let tagEditOptions = {
showSuggestions: true,
saveHandler: null,
onSaved: null,
normalizeTag: true,
};
function normalizeModelTypeKey(modelType) { function normalizeModelTypeKey(modelType) {
if (!modelType) { if (!modelType) {
return ''; return '';
@@ -140,13 +148,30 @@ let saveTagsHandler = null;
/** /**
* Set up tag editing mode * Set up tag editing mode
* @param {string|null} modelType - Model type for suggestions (e.g. 'loras', 'checkpoints')
* @param {Object} [options] - Optional configuration
* @param {boolean} [options.showSuggestions=true] - Show priority tag suggestions dropdown
* @param {Function} [options.saveHandler] - Custom save function, async (filePath, tags) => {}
* @param {Function} [options.onSaved] - Called after successful save, (tags) => {}
* @param {boolean} [options.normalizeTag=true] - Lowercase tag on add
*/ */
export function setupTagEditMode(modelType = null) { export function setupTagEditMode(modelType = null, options = {}) {
const editBtn = document.querySelector('.edit-tags-btn'); // Store options for use by saveTags and addNewTag
tagEditOptions = {
showSuggestions: options.showSuggestions !== false,
saveHandler: options.saveHandler || null,
onSaved: options.onSaved || null,
normalizeTag: options.normalizeTag !== false,
};
const root = options.container || document;
const editBtn = root.querySelector('.edit-tags-btn');
if (!editBtn) return; if (!editBtn) return;
if (tagEditOptions.showSuggestions) {
setActiveModelTypeKey(modelType); setActiveModelTypeKey(modelType);
ensurePriorityTagSuggestions(); ensurePriorityTagSuggestions();
}
// Store original tags for restoring on cancel // Store original tags for restoring on cancel
let originalTags = []; let originalTags = [];
@@ -158,7 +183,8 @@ export function setupTagEditMode(modelType = null) {
// Create new handler and store reference // Create new handler and store reference
const editBtnClickHandler = function() { const editBtnClickHandler = function() {
const tagsSection = document.querySelector('.model-tags-container'); const tagsSection = this.closest('.model-tags-container');
if (!tagsSection) return;
const isEditMode = tagsSection.classList.toggle('edit-mode'); const isEditMode = tagsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath; const filePath = this.dataset.filePath;
@@ -193,16 +219,18 @@ export function setupTagEditMode(modelType = null) {
tagsSection.appendChild(editContainer); tagsSection.appendChild(editContainer);
// Setup the tag input field behavior // Setup the tag input field behavior
setupTagInput(); setupTagInput(tagsSection);
// Create and add preset suggestions dropdown // Create and add preset suggestions dropdown
if (tagEditOptions.showSuggestions) {
const tagForm = editContainer.querySelector('.metadata-add-form'); const tagForm = editContainer.querySelector('.metadata-add-form');
const suggestionsDropdown = createSuggestionsDropdown(originalTags); const suggestionsDropdown = createSuggestionsDropdown(originalTags);
tagForm.appendChild(suggestionsDropdown); tagForm.appendChild(suggestionsDropdown);
}
// Setup delete buttons for existing tags // Setup delete buttons for existing tags
setupDeleteButtons(); setupDeleteButtons();
setupTagDragAndDrop(); setupTagDragAndDrop(tagsSection);
// Transfer click event from original button to the cloned one // Transfer click event from original button to the cloned one
const newEditBtn = editContainer.querySelector('.metadata-header-btn'); const newEditBtn = editContainer.querySelector('.metadata-header-btn');
@@ -218,7 +246,7 @@ export function setupTagEditMode(modelType = null) {
// Just show the existing edit container // Just show the existing edit container
tagsEditContainer.style.display = 'block'; tagsEditContainer.style.display = 'block';
editBtn.style.display = 'none'; editBtn.style.display = 'none';
setupTagDragAndDrop(); setupTagDragAndDrop(tagsSection);
} }
} else { } else {
// Exit edit mode // Exit edit mode
@@ -255,7 +283,7 @@ export function setupTagEditMode(modelType = null) {
saveTagsHandler = function(e) { saveTagsHandler = function(e) {
if (e.target.classList.contains('save-tags-btn') || if (e.target.classList.contains('save-tags-btn') ||
e.target.closest('.save-tags-btn')) { e.target.closest('.save-tags-btn')) {
saveTags(); saveTags(e.target);
} }
}; };
@@ -267,19 +295,28 @@ export function setupTagEditMode(modelType = null) {
/** /**
* Save tags * Save tags
* @param {Element} [triggerElement] - The element that triggered the save (e.g. save button)
*/ */
async function saveTags() { async function saveTags(triggerElement = null) {
const editBtn = document.querySelector('.edit-tags-btn'); let editBtn;
if (!editBtn) return; let scope;
if (triggerElement) {
scope = triggerElement.closest('.model-tags-container');
editBtn = scope ? scope.querySelector('.edit-tags-btn') : document.querySelector('.edit-tags-btn');
} else {
scope = document.querySelector('.model-tags-container');
editBtn = scope ? scope.querySelector('.edit-tags-btn') : null;
}
if (!editBtn || !scope) return;
const filePath = editBtn.dataset.filePath; const filePath = editBtn.dataset.filePath;
const tagElements = document.querySelectorAll('.metadata-item'); const tagElements = scope.querySelectorAll('.metadata-item');
let tags = Array.from(tagElements).map(tag => tag.dataset.tag); let tags = Array.from(tagElements).map(tag => tag.dataset.tag);
// Flush uncommitted input as a tag so it's not silently lost on save // Flush uncommitted input as a tag so it's not silently lost on save
const tagInput = document.querySelector('.metadata-input'); const tagInput = scope.querySelector('.metadata-input');
if (tagInput) { if (tagInput) {
const pendingTag = tagInput.value.trim().toLowerCase(); const pendingTag = tagEditOptions.normalizeTag ? tagInput.value.trim().toLowerCase() : tagInput.value.trim();
if (pendingTag && !tags.includes(pendingTag)) { if (pendingTag && !tags.includes(pendingTag)) {
tags.push(pendingTag); tags.push(pendingTag);
} }
@@ -287,7 +324,7 @@ async function saveTags() {
} }
// Get original tags to compare // Get original tags to compare
const originalTagElements = document.querySelectorAll('.tooltip-tag'); const originalTagElements = scope.querySelectorAll('.tooltip-tag');
const originalTags = Array.from(originalTagElements).map(tag => tag.textContent); const originalTags = Array.from(originalTagElements).map(tag => tag.textContent);
// Check if tags have actually changed // Check if tags have actually changed
@@ -301,14 +338,22 @@ async function saveTags() {
} }
try { try {
// Save tags metadata // Use custom save handler if provided, otherwise default model API
if (tagEditOptions.saveHandler) {
await tagEditOptions.saveHandler(filePath, tags);
} else {
await getModelApiClient().saveModelMetadata(filePath, { tags: tags }); await getModelApiClient().saveModelMetadata(filePath, { tags: tags });
}
// Set flag to skip restoring original tags when exiting edit mode // Set flag to skip restoring original tags when exiting edit mode
editBtn.dataset.skipRestore = "true"; editBtn.dataset.skipRestore = "true";
// Use custom onSaved if provided (e.g. for recipe dirty state + re-render)
if (tagEditOptions.onSaved) {
tagEditOptions.onSaved(tags);
} else {
// Update the compact tags display // Update the compact tags display
const compactTagsContainer = document.querySelector('.model-tags-container'); const compactTagsContainer = scope;
if (compactTagsContainer) { if (compactTagsContainer) {
// Generate new compact tags HTML // Generate new compact tags HTML
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact'); const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
@@ -353,6 +398,7 @@ async function saveTags() {
// Exit edit mode // Exit edit mode
editBtn.click(); editBtn.click();
}
showToast('modelTags.messages.updated', {}, 'success'); showToast('modelTags.messages.updated', {}, 'success');
} catch (error) { } catch (error) {
@@ -470,16 +516,19 @@ function renderPriorityTagSuggestions(container, existingTags = []) {
/** /**
* Set up tag input behavior * Set up tag input behavior
* @param {Element} scopeContainer - The .model-tags-container element
*/ */
function setupTagInput() { function setupTagInput(scopeContainer) {
const tagInput = document.querySelector('.metadata-input'); const tagInput = scopeContainer
? scopeContainer.querySelector('.metadata-input')
: document.querySelector('.metadata-input');
if (tagInput) { if (tagInput) {
tagInput.focus(); tagInput.focus();
tagInput.addEventListener('keydown', function(e) { tagInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
addNewTag(this.value); addNewTag(this.value, this);
this.value = ''; // Clear input after adding this.value = ''; // Clear input after adding
} }
}); });
@@ -504,9 +553,12 @@ function setupDeleteButtons() {
/** /**
* Enable drag-and-drop sorting for tag items * Enable drag-and-drop sorting for tag items
* @param {Element} [scopeContainer] - Optional scoped .model-tags-container element
*/ */
function setupTagDragAndDrop() { function setupTagDragAndDrop(scopeContainer) {
const container = document.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR); const container = scopeContainer
? scopeContainer.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR)
: document.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR);
if (!container) { if (!container) {
return; return;
} }
@@ -712,12 +764,14 @@ function finishPointerDrag() {
/** /**
* Add a new tag * Add a new tag
* @param {string} tag - Tag to add * @param {string} tag - Tag to add
* @param {Element} [scopeElement] - Element within the correct .model-tags-container for scoping
*/ */
function addNewTag(tag) { function addNewTag(tag, scopeElement = null) {
tag = tag.trim().toLowerCase(); tag = tagEditOptions.normalizeTag ? tag.trim().toLowerCase() : tag.trim();
if (!tag) return; if (!tag) return;
const tagsContainer = document.querySelector('.metadata-items'); const scope = scopeElement ? scopeElement.closest('.model-tags-container') : document;
const tagsContainer = scope.querySelector('.metadata-items');
if (!tagsContainer) return; if (!tagsContainer) return;
// Validation: Check length // Validation: Check length
@@ -762,7 +816,7 @@ function addNewTag(tag) {
}); });
tagsContainer.appendChild(newTag); tagsContainer.appendChild(newTag);
setupTagDragAndDrop(); setupTagDragAndDrop(scope);
// Update status of items in the suggestions dropdown // Update status of items in the suggestions dropdown
updateSuggestionsDropdown(); updateSuggestionsDropdown();
@@ -6,6 +6,7 @@ import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js'; import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
import { formatFileSize } from './utils.js'; import { formatFileSize } from './utils.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv']; const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png'; const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
@@ -306,7 +307,7 @@ function getToggleTooltipText(mode) {
} }
function getDefaultDisplayMode() { function getDefaultDisplayMode() {
const strategy = state?.global?.settings?.update_flag_strategy; const strategy = state?.global?.settings?.version_grouping;
return strategy === DISPLAY_FILTER_MODES.SAME_BASE return strategy === DISPLAY_FILTER_MODES.SAME_BASE
? DISPLAY_FILTER_MODES.SAME_BASE ? DISPLAY_FILTER_MODES.SAME_BASE
: DISPLAY_FILTER_MODES.ANY; : DISPLAY_FILTER_MODES.ANY;
@@ -338,7 +339,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
return false; return false;
} }
const strategy = state?.global?.settings?.update_flag_strategy; const strategy = state?.global?.settings?.version_grouping;
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE; const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates; const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
@@ -744,7 +745,7 @@ function renderToolbar(record, toolbarState = {}) {
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-versions-action="toggle-model-ignore"> <button class="versions-toolbar-btn versions-toolbar-btn-primary" data-versions-action="toggle-model-ignore">
${escapeHtml(ignoreText)} ${escapeHtml(ignoreText)}
</button> </button>
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Coming soon'))}" disabled> <button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Show all local versions of this model on the main page'))}">
${escapeHtml(viewLocalText)} ${escapeHtml(viewLocalText)}
</button> </button>
</div> </div>
@@ -792,6 +793,7 @@ export function initVersionsTab({
modelId, modelId,
currentVersionId, currentVersionId,
currentBaseModel, currentBaseModel,
modelName,
onUpdateStatusChange, onUpdateStatusChange,
}) { }) {
const pane = document.querySelector(`#${modalId} #versions-tab`); const pane = document.querySelector(`#${modalId} #versions-tab`);
@@ -1019,6 +1021,39 @@ export function initVersionsTab({
render(controller.record); render(controller.record);
} }
function handleViewLocalVersions() {
if (!controller.record || !modelId) {
return;
}
// Determine base model filter based on current display mode
const baseModelInfo = getCurrentVersionBaseModel(controller.record, normalizedCurrentVersionId);
const isFilteringActive =
displayMode === DISPLAY_FILTER_MODES.SAME_BASE &&
Boolean(baseModelInfo.normalized);
// Write filter params to sessionStorage (page-scoped)
setSessionItem('vlm_model_id', String(modelId));
setSessionItem('vlm_model_name', modelName || String(modelId));
setSessionItem('vlm_page_type', modelType);
if (isFilteringActive) {
// Use raw (non-normalized) base model for exact backend matching
setSessionItem('vlm_base_model', baseModelInfo.raw);
} else {
removeSessionItem('vlm_base_model');
}
// Close the modal and navigate via no-reload VLM flow
modalManager.closeModal(modalId);
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
window.pageControls.triggerVlmView(
modelId,
modelName || String(modelId),
isFilteringActive ? baseModelInfo.raw : undefined,
modelType
);
}
}
async function handleToggleVersionIgnore(button, versionId) { async function handleToggleVersionIgnore(button, versionId) {
if (!controller.record) { if (!controller.record) {
return; return;
@@ -1348,6 +1383,10 @@ export function initVersionsTab({
event.preventDefault(); event.preventDefault();
handleToggleVersionDisplayMode(); handleToggleVersionDisplayMode();
break; break;
case 'view-local':
event.preventDefault();
handleViewLocalVersions();
break;
default: default:
break; break;
} }
@@ -3,7 +3,7 @@
* Media-specific utility functions for showcase components * Media-specific utility functions for showcase components
* (Moved from uiHelpers.js to better organize code) * (Moved from uiHelpers.js to better organize code)
*/ */
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js'; import { showToast, copyToClipboard, getNSFWLevelName, sendPromptToWorkflow, stripLoraTags, sendGenParamsToWorkflow } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js'; import { state } from '../../../state/index.js';
import { getModelApiClient } from '../../../api/modelApiFactory.js'; import { getModelApiClient } from '../../../api/modelApiFactory.js';
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js'; import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
@@ -318,6 +318,74 @@ export function initMetadataPanelHandlers(container) {
}); });
}); });
// Handle send prompt buttons
const sendBtns = metadataPanel.querySelectorAll('.send-prompt-btn');
sendBtns.forEach(sendBtn => {
const promptIndex = sendBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
sendBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
let promptText = promptElement.textContent || '';
if (!promptText.trim()) {
showToast('toast.recipes.noPromptToSend', {}, 'warning');
return;
}
// Respect strip <lora> setting from global state
if (state.global.settings?.strip_lora_on_copy) {
promptText = stripLoraTags(promptText);
}
sendPromptToWorkflow(promptText);
});
});
// Handle send params buttons
const paramsBtn = metadataPanel.querySelector('.send-params-btn');
if (paramsBtn) {
paramsBtn.addEventListener('click', async (e) => {
e.stopPropagation();
// Collect gen params from the param-tag elements
const tagsContainer = wrapper.querySelector('.params-tags');
if (!tagsContainer) return;
const paramTags = tagsContainer.querySelectorAll('.param-tag');
const genParams = {};
// Map display labels to genParams keys
const labelToKey = {
'Seed': 'seed',
'Steps': 'steps',
'Sampler': 'sampler',
'CFG': 'cfg_scale',
};
paramTags.forEach(tag => {
const nameEl = tag.querySelector('.param-name');
const valueEl = tag.querySelector('.param-value');
if (!nameEl || !valueEl) return;
const label = nameEl.textContent.replace(':', '').trim();
const key = labelToKey[label];
if (key) {
genParams[key] = valueEl.textContent.trim();
}
});
if (Object.keys(genParams).length === 0) {
showToast('No sendable parameters found', {}, 'warning');
return;
}
await sendGenParamsToWorkflow(genParams);
});
}
// Prevent panel scroll from causing modal scroll // Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => { metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0; const isAtTop = metadataPanel.scrollTop === 0;
@@ -28,6 +28,15 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
if (hasParams) { if (hasParams) {
content += ` content += `
<div class="metadata-row params-row">
<div class="param-header">
<span class="metadata-label">Params:</span>
<div class="param-actions">
<button class="send-params-btn" title="Send Params to Workflow">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
<div class="params-tags"> <div class="params-tags">
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''} ${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''} ${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
@@ -37,6 +46,7 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''} ${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''} ${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
</div> </div>
</div>
`; `;
} }
@@ -53,14 +63,21 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
prompt = escapeHtml(prompt); prompt = escapeHtml(prompt);
content += ` content += `
<div class="metadata-row prompt-row"> <div class="metadata-row prompt-row">
<div class="param-header">
<span class="metadata-label">Prompt:</span> <span class="metadata-label">Prompt:</span>
<div class="metadata-prompt-wrapper"> <div class="param-actions">
<div class="metadata-prompt">${prompt}</div> <button class="send-prompt-btn" data-prompt-index="${promptIndex}" title="Send Prompt to Workflow">
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}"> <i class="fas fa-paper-plane"></i>
</button>
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}" title="Copy Prompt">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="metadata-prompt-wrapper">
<div class="metadata-prompt">${prompt}</div>
</div>
</div>
<div class="hidden-prompt" id="prompt-${promptIndex}" style="display:none;">${prompt}</div> <div class="hidden-prompt" id="prompt-${promptIndex}" style="display:none;">${prompt}</div>
`; `;
} }
@@ -69,14 +86,21 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
negativePrompt = escapeHtml(negativePrompt); negativePrompt = escapeHtml(negativePrompt);
content += ` content += `
<div class="metadata-row prompt-row"> <div class="metadata-row prompt-row">
<div class="param-header">
<span class="metadata-label">Negative Prompt:</span> <span class="metadata-label">Negative Prompt:</span>
<div class="metadata-prompt-wrapper"> <div class="param-actions">
<div class="metadata-prompt">${negativePrompt}</div> <button class="send-prompt-btn" data-prompt-index="${negPromptIndex}" title="Send Negative Prompt to Workflow">
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}"> <i class="fas fa-paper-plane"></i>
</button>
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}" title="Copy Negative Prompt">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="metadata-prompt-wrapper">
<div class="metadata-prompt">${negativePrompt}</div>
</div>
</div>
<div class="hidden-prompt" id="prompt-${negPromptIndex}" style="display:none;">${negativePrompt}</div> <div class="hidden-prompt" id="prompt-${negPromptIndex}" style="display:none;">${negativePrompt}</div>
`; `;
} }
+5 -3
View File
@@ -78,10 +78,12 @@ export function renderCompactTags(tags, filePath = '') {
/** /**
* Set up tag tooltip functionality * Set up tag tooltip functionality
* @param {Element} [scopeContainer] - Optional container to scope the querySelector
*/ */
export function setupTagTooltip() { export function setupTagTooltip(scopeContainer = null) {
const tagsContainer = document.querySelector('.model-tags-container'); const root = scopeContainer || document;
const tooltip = document.querySelector('.model-tags-tooltip'); const tagsContainer = root.querySelector('.model-tags-container');
const tooltip = root.querySelector('.model-tags-tooltip');
if (tagsContainer && tooltip) { if (tagsContainer && tooltip) {
tagsContainer.addEventListener('mouseenter', () => { tagsContainer.addEventListener('mouseenter', () => {

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