Compare commits

...

33 Commits

Author SHA1 Message Date
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
79 changed files with 4545 additions and 1163 deletions

1
.gitignore vendored
View File

@@ -19,6 +19,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)

File diff suppressed because one or more lines are too long

View File

@@ -12,31 +12,40 @@
"2018cfh", "2018cfh",
"Arlecchino Shion", "Arlecchino Shion",
"Rob Williams", "Rob Williams",
"Charles Blakemore",
"W+K+White", "W+K+White",
"$MetaSamsara", "$MetaSamsara",
"wackop", "wackop",
"Phil", "Phil",
"Carl G.", "Carl G.",
"Charles Blakemore",
"stone9k", "stone9k",
"Rosenthal",
"Francisco Tatis",
"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",
"Tobi_Swagg", "Tobi_Swagg",
"SG", "SG",
"zenbound",
"jmack", "jmack",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"Ricky Carter", "Ricky Carter",
"James Todd",
"JongWon Han", "JongWon Han",
"VantAI", "VantAI",
"レプサイ", "レプサイ",
@@ -47,6 +56,7 @@
"JackieWang", "JackieWang",
"FreelancerZ", "FreelancerZ",
"fnkylove", "fnkylove",
"Vik71it",
"Echo", "Echo",
"Lilleman", "Lilleman",
"Robert Stacey", "Robert Stacey",
@@ -54,19 +64,14 @@
"Edgar Tejeda", "Edgar Tejeda",
"Fraser Cross", "Fraser Cross",
"Liam MacDougal", "Liam MacDougal",
"Polymorphic Indeterminate",
"Sterilized", "Sterilized",
"JORGE+LUIZ+HUSSNI+MESSIAS", "JORGE+LUIZ+HUSSNI+MESSIAS",
"Marc Whiffen",
"Skalabananen",
"Birdy",
"quarz", "quarz",
"Reno Lam", "Greg",
"JSST", "JSST",
"sig",
"J\\B/ 8r0wns0n",
"Snaggwort", "Snaggwort",
"Takkan", "Takkan",
"wfpearl",
"Matt+J", "Matt+J",
"Baekdoosixt", "Baekdoosixt",
"Jonathan Ross", "Jonathan Ross",
@@ -80,11 +85,13 @@
"Release Cabrakan", "Release Cabrakan",
"JW Sin", "JW Sin",
"Alex", "Alex",
"bh",
"carozzz", "carozzz",
"Marlon Daniels", "Marlon Daniels",
"James Dooley", "James Dooley",
"zenbound",
"Buzzard", "Buzzard",
"Aaron Bleuer",
"LacesOut!",
"Adam Shaw", "Adam Shaw",
"Mark Corneglio", "Mark Corneglio",
"SarcasticHashtag", "SarcasticHashtag",
@@ -92,22 +99,24 @@
"iamresist", "iamresist",
"RedrockVP", "RedrockVP",
"Wolffen", "Wolffen",
"James Todd",
"Wicked Choices by ASLPro3D", "Wicked Choices by ASLPro3D",
"Jacob Hoehler",
"FinalyFree", "FinalyFree",
"Weasyl",
"Steven Pfeiffer", "Steven Pfeiffer",
"Timmy", "Timmy",
"Johnny", "Johnny",
"Cory Paza",
"Tak", "Tak",
"Lisster", "Lisster",
"Big Red", "Big Red",
"whudunit", "whudunit",
"Luc Job", "Luc Job",
"dl0901dm", "dl0901dm",
"Philip Hempel",
"corde", "corde",
"Nick Walker", "nwalker94",
"Yushio", "Yushio",
"Vik71it",
"Bishoujoker", "Bishoujoker",
"Todd Keck", "Todd Keck",
"Briton Heilbrun", "Briton Heilbrun",
@@ -117,64 +126,68 @@
"AM Kuro", "AM Kuro",
"BadassArabianMofo", "BadassArabianMofo",
"Pascal Dahle", "Pascal Dahle",
"Greg", "Sangheili460",
"MagnaInsomnia",
"Akira_HentAI", "Akira_HentAI",
"Karl P.",
"lmsupporter", "lmsupporter",
"andrew.tappan", "andrew.tappan",
"N/A",
"graysock",
"Greenmoustache", "Greenmoustache",
"zounic", "zounic",
"wfpearl", "fancypants",
"Eldithor", "Eldithor",
"Jack B Nimble", "Jack B Nimble",
"JaxMax", "JaxMax",
"contrite831", "contrite831",
"bh",
"Jwk0205", "Jwk0205",
"Starkselle", "Starkselle",
"Olive", "Olive",
"Aaron Bleuer",
"LacesOut!",
"greebles", "greebles",
"Some Guy Named Barry", "Some Guy Named Barry",
"M Postkasse", "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",
"Dustin Chen",
"dan", "dan",
"aai", "aai",
"Mouthlessman",
"otaku fra", "otaku fra",
"jean jahren", "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", "The Spawn",
"graysock",
"Pozadine1", "Pozadine1",
"Qarob", "Qarob",
"AIGooner", "AIGooner",
"Luc", "Luc",
"ProtonPrince", "ProtonPrince",
"DiffDuck", "DiffDuck",
"elu3199",
"Hasturkun",
"Jon Sandman",
"Ubivis",
"Jackthemind", "Jackthemind",
"fancypants",
"Joboshy", "Joboshy",
"Digital", "Digital",
"takyamtom", "takyamtom",
@@ -183,24 +196,20 @@
"Bro Xie", "Bro Xie",
"yer fey", "yer fey",
"batblue", "batblue",
"Error_Rule34_Not_found",
"carey6409", "carey6409",
"太郎 ゲーム", "太郎 ゲーム",
"Roslynd", "Roslynd",
"jinxedx", "jinxedx",
"Neco28",
"Cosmosis", "Cosmosis",
"AELOX", "David Ortega",
"Dankin", "Dankin",
"Nicfit23",
"FloPro4Sho", "FloPro4Sho",
"Cristian Vazquez", "Cristian Vazquez",
"wamekukyouzin",
"drum matthieu",
"Dogmaster",
"Frank Nitty", "Frank Nitty",
"Magic Noob", "Magic Noob",
"Christopher Michel",
"DougPeterson", "DougPeterson",
"LeoZero",
"Antonio Pontes", "Antonio Pontes",
"ApathyJones", "ApathyJones",
"Bruce", "Bruce",
@@ -208,19 +217,14 @@
"Steven Owens", "Steven Owens",
"nahinahi9", "nahinahi9",
"Kevin John Duck", "Kevin John Duck",
"Dustin Chen", "Kevin Christopher",
"Blackfish95", "Blackfish95",
"Mouthlessman",
"Paul Kroll", "Paul Kroll",
"Bas Imagineer", "Bas Imagineer",
"John Statham", "John Statham",
"yuxz69", "yuxz69",
"esthe", "esthe",
"decoy", "decoy",
"elu3199",
"Hasturkun",
"Jon Sandman",
"Ubivis",
"CloudValley", "CloudValley",
"thesoftwaredruid", "thesoftwaredruid",
"wundershark", "wundershark",
@@ -232,52 +236,57 @@
"MJG", "MJG",
"David LaVallee", "David LaVallee",
"linnfrey", "linnfrey",
"ae",
"Tr4shP4nda",
"capn",
"Joseph",
"IamAyam", "IamAyam",
"skaterb949", "skaterb949",
"Brian M",
"Josef Lanzl", "Josef Lanzl",
"Nerezza", "Nerezza",
"sanborondon", "sanborondon",
"confiscated Zyra", "confiscated Zyra",
"Error_Rule34_Not_found",
"Taylor Funk", "Taylor Funk",
"aezin", "aezin",
"Thought2Form",
"jcay015", "jcay015",
"Gerald Welly", "Gerald Welly",
"Kevin Picco",
"Erik Lopez", "Erik Lopez",
"Mateo Curić", "Mateo Curić",
"Tee Gee", "Tee Gee",
"Geolog", "Geolog",
"tarek helmi", "tarek helmi",
"Neco28",
"Eris3D", "Eris3D",
"Max Marklund", "Max Marklund",
"David Ortega", "m",
"Pronredn", "Pronredn",
"Jamie Ogletree",
"a _", "a _",
"Jeff", "Jeff",
"lh qwe", "lh qwe",
"James Coleman", "James Coleman",
"conner", "conner",
"Kevin Christopher", "Ouro Boros",
"Chad Idk", "Chad Idk",
"dd", "dd",
"Princess Bright Eyes", "Princess Bright Eyes",
"Yuji Kaneko",
"Dušan Ryban", "Dušan Ryban",
"Felipe dos Santos", "Felipe dos Santos",
"Sam", "Sam",
"sjon kreutz", "sjon kreutz",
"Ace Ventura",
"Douglas Gaspar", "Douglas Gaspar",
"Metryman55", "Metryman55",
"AlexDuKaNa", "AlexDuKaNa",
"George", "George",
"dw", "dw",
"地獄の禄", "地獄の禄",
"ae",
"Tr4shP4nda",
"Gamalonia", "Gamalonia",
"WRL_SPR", "WRL_SPR",
"capn", "momokai",
"Joseph",
"Mirko Katzula", "Mirko Katzula",
"dan", "dan",
"Piccio08", "Piccio08",
@@ -289,58 +298,11 @@
"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",
@@ -351,6 +313,51 @@
"kyoumei", "kyoumei",
"RadStorm04", "RadStorm04",
"JohnDoe42054", "JohnDoe42054",
"gzmzmvp",
"Richard",
"奚明 刘",
"Andrew",
"Robert Wegemund",
"Littlehuggy",
"준희 김",
"Brian Buie",
"Sadlip",
"Eric Whitney",
"Joey Callahan",
"Ivan Tadic",
"Tomohiro Baba",
"Mike Simone",
"Noora",
"Pierce McBride",
"Joshua Gray",
"Mattssn",
"Mikko Hemilä",
"Jacob McDaniel",
"Temikus",
"Artokun",
"Michael Taylor",
"Derek Baker",
"Martial",
"Michael Anthony Scott",
"Emil Andersson",
"Atilla Berke Pekduyar",
"Steam Steam",
"CryptoTraderJK",
"Decx _",
"Davaitamin",
"Rops Alot",
"tedcor",
"Fotek Design",
"四糸凜音",
"Nihongasuki",
"LarsesFPC",
"MadSpin",
"FrxzenSnxw",
"inbijiburu",
"Nick “Loadstone” D",
"starbugx",
"dc7431",
"Crocket",
"BillyHill", "BillyHill",
"emyth", "emyth",
"chriphost", "chriphost",
@@ -370,32 +377,37 @@
"Vir", "Vir",
"Skyfire83", "Skyfire83",
"Adam Rinehart", "Adam Rinehart",
"gzmzmvp", "Pitpe11",
"TheD1rtyD03",
"moonpetal",
"g9p0o",
"TheHolySheep",
"Monte Won",
"Gregory Kozhemiak", "Gregory Kozhemiak",
"Draven T", "Draven T",
"mrjuan", "mrjuan",
"Eric Whitney", "elleshar666",
"Joey Callahan",
"Aquatic Coffee", "Aquatic Coffee",
"Ivan Tadic",
"Mike Simone",
"John J Linehan", "John J Linehan",
"ethanfel", "ethanfel",
"Elliot E", "Elliot E",
"Morgandel", "Morgandel",
"Theerat Jiramate", "Theerat Jiramate",
"Focuschannel", "Focuschannel",
"Edward Kennedy",
"Noah", "Noah",
"X", "X",
"Sloan Steddy", "Sloan Steddy",
"Vane Holzer",
"hexxish", "hexxish",
"Derek Baker",
"Anthony Faxlandez", "Anthony Faxlandez",
"battu", "battu",
"notedfakes",
"Nathan", "Nathan",
"NICHOLAS BAXLEY", "NICHOLAS BAXLEY",
"Pat Hen", "Pat Hen",
"Xeeosat", "Xeeosat",
"Saya",
"Ed Wang", "Ed Wang",
"Jordan Shaw", "Jordan Shaw",
"g unit", "g unit",
@@ -403,26 +415,24 @@
"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",
"SpringBootisTrash", "SpringBootisTrash",
"carsten", "carsten",
"ikok", "ikok",
"Doug+Rintoul",
"Noor",
"Yorunai",
"quantenmecha", "quantenmecha",
"Jason+Nash", "Jason+Nash",
"DarkRoast", "DarkRoast",
@@ -440,23 +450,22 @@
"blikkies", "blikkies",
"Chris", "Chris",
"Time Valentine", "Time Valentine",
"elleshar666",
"Shock Shockor", "Shock Shockor",
"ACTUALLY_the_Real_Willem_Dafoe", "ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч", "Михал Михалыч",
"Matt",
"Goldwaters", "Goldwaters",
"Kauffy", "Kauffy",
"Zude", "Zude",
"SPJ",
"Kyler", "Kyler",
"Edward Kennedy",
"Justin Blaylock", "Justin Blaylock",
"aRtFuL_DodGeR", "aRtFuL_DodGeR",
"Nick Kage", "Nick Kage",
"Vane Holzer",
"psytrax", "psytrax",
"Cyrus Fett", "Cyrus Fett",
"Xenon Xue", "Xenon Xue",
"notedfakes", "Edward Ten Eyck",
"Billy Gladky", "Billy Gladky",
"Michael Scott", "Michael Scott",
"Probis", "Probis",
@@ -465,9 +474,9 @@
"ItsGeneralButtNaked", "ItsGeneralButtNaked",
"Donor4115", "Donor4115",
"Distortik", "Distortik",
"Manu Thetug",
"Filippo Ferrari", "Filippo Ferrari",
"Youguang", "Youguang",
"Saya",
"andrewzpong", "andrewzpong",
"BossGame", "BossGame",
"lrdchs", "lrdchs",
@@ -476,9 +485,12 @@
"AIVORY3D", "AIVORY3D",
"Kevinj", "Kevinj",
"Mitchell Robson", "Mitchell Robson",
"Whitepinetrader",
"POPPIN", "POPPIN",
"nanana", "G",
"Tan+Huynh",
"Bob+Barker",
"D",
"Dark_Pest",
"Alex", "Alex",
"Karru", "Karru",
"ChaChanoKo", "ChaChanoKo",
@@ -493,9 +505,6 @@
"Alan+Cano", "Alan+Cano",
"FeralOpticsAI", "FeralOpticsAI",
"Pavlaki", "Pavlaki",
"Doug+Rintoul",
"Noor",
"Yorunai",
"BillyBoy84", "BillyBoy84",
"Buecyb99", "Buecyb99",
"Welkor", "Welkor",
@@ -504,25 +513,30 @@
"JBsuede", "JBsuede",
"moranqianlong", "moranqianlong",
"Kalli Core", "Kalli Core",
"Ronan Delevacq",
"Christian Schäfer", "Christian Schäfer",
"りん あめ", "りん あめ",
"Matt", "Dave Abraham",
"Joaquin Hierrezuelo",
"Locrospiel", "Locrospiel",
"Frogmilk", "Frogmilk",
"SPJ", "Sean voets",
"Kor", "Kor",
"Joseph Hanson", "Joseph Hanson",
"John Rednoulf",
"Kyron Mahan", "Kyron Mahan",
"Bryan Rutkowski", "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",
@@ -533,7 +547,6 @@
"jinksta187", "jinksta187",
"RHopkirk", "RHopkirk",
"Andrew Wilkinson", "Andrew Wilkinson",
"Manu Thetug",
"Karlanx", "Karlanx",
"Lyavph", "Lyavph",
"Maxim", "Maxim",
@@ -558,6 +571,18 @@
"Scott", "Scott",
"Muratoraccio", "Muratoraccio",
"D", "D",
"SAVEagleBasement",
"Kevin+Isom",
"Rune+Osnes",
"you+halo9",
"cloudghost",
"Yongkwan+Lee",
"PoorStudent",
"lucites",
"Alex+Zaw",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"nickname", "nickname",
"Sildoren", "Sildoren",
"Darv", "Darv",
@@ -581,49 +606,40 @@
"Flob", "Flob",
"ShiroSenpai", "ShiroSenpai",
"Inkognito", "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",
"edk", "edk",
"Tú Nguyễn Lý Hoàng", "Tú Nguyễn Lý Hoàng",
"shira1011", "shira1011",
"Ben D", "Ben D",
"G", "G",
"Ronan Delevacq",
"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", "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",
"MR.Bear", "MR.Bear",
"Jack Dole", "Jack Dole",
"matt", "matt",
"somethingtosay8", "somethingtosay8",
"Terminuz",
"ivistorm", "ivistorm",
"max blo", "max blo",
"Sauv",
"Steven",
"CptNeo", "CptNeo",
"Jack Lawfield",
"Borte",
"Maso", "Maso",
"Ted Cart", "Ted Cart",
"Sage Himeros", "Sage Himeros",
@@ -637,11 +653,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 +706,12 @@
"SelfishMedic", "SelfishMedic",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"thomasand01",
"Shiba+Sama",
"miduzza",
"KB",
"shw",
"Celestial+Kitten",
"bakeliteboy", "bakeliteboy",
"TequiTequi", "TequiTequi",
"Homero+Banda", "Homero+Banda",
@@ -708,24 +732,12 @@
"imer", "imer",
"Akkas+Haque", "Akkas+Haque",
"Kachac", "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,13 +750,13 @@
"Bula", "Bula",
"KUJYAKU", "KUJYAKU",
"Coeur+de+cochon", "Coeur+de+cochon",
"Obsidian.Studios",
"han b", "han b",
"Zomba Mann", "Zomba Mann",
"Aquaneo", "Aquaneo",
"Nico", "Nico",
"Maximilian Krischan", "Maximilian Krischan",
"Banana Joe", "Banana Joe",
"proto merp",
"_ G3n", "_ G3n",
"Donovan Jenkins", "Donovan Jenkins",
"Hans Meier", "Hans Meier",
@@ -763,9 +775,13 @@
"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",
@@ -783,23 +799,23 @@
"TheFusion", "TheFusion",
"Jean-françois SEMA", "Jean-françois SEMA",
"3zS4QNQ4", "3zS4QNQ4",
"Terminuz",
"Kurt", "Kurt",
"Matt M.", "Matt M.",
"Ivan Imes", "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 +837,5 @@
"Somebody", "Somebody",
"CK" "CK"
], ],
"totalCount": 818 "totalCount": 834
} }

View File

@@ -145,6 +145,10 @@
}, },
"usage": { "usage": {
"timesUsed": "Verwendungsanzahl" "timesUsed": "Verwendungsanzahl"
},
"footer": {
"versionCount": "{count} Versionen",
"viewAllVersions": "Alle lokalen Versionen anzeigen"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +187,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Ausgeschlossene Modelle verwalten" "label": "Ausgeschlossene Modelle verwalten"
},
"groupByModel": {
"label": "Nach Modell gruppieren"
} }
}, },
"header": { "header": {
@@ -195,13 +202,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",
@@ -325,7 +326,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",
@@ -430,6 +431,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",
@@ -586,7 +589,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": {
@@ -670,7 +673,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",
@@ -1016,6 +1023,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",
@@ -1033,13 +1052,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": {
@@ -1532,12 +1615,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",
@@ -1724,6 +1810,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",

View File

@@ -145,6 +145,10 @@
}, },
"usage": { "usage": {
"timesUsed": "Times used" "timesUsed": "Times used"
},
"footer": {
"versionCount": "{count} versions",
"viewAllVersions": "View all local versions"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +187,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Manage Excluded Models" "label": "Manage Excluded Models"
},
"groupByModel": {
"label": "Group by Model"
} }
}, },
"header": { "header": {
@@ -195,13 +202,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",
@@ -325,7 +326,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",
@@ -430,6 +431,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",
@@ -586,12 +589,12 @@
"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": {
@@ -670,7 +673,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",
@@ -1016,6 +1023,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",
@@ -1033,13 +1052,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": {
@@ -1387,7 +1470,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",
@@ -1532,12 +1615,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",
@@ -1724,6 +1810,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",

View File

@@ -145,6 +145,10 @@
}, },
"usage": { "usage": {
"timesUsed": "Veces usado" "timesUsed": "Veces usado"
},
"footer": {
"versionCount": "{count} versiones",
"viewAllVersions": "Ver todas las versiones locales"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +187,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Gestionar modelos excluidos" "label": "Gestionar modelos excluidos"
},
"groupByModel": {
"label": "Agrupar por modelo"
} }
}, },
"header": { "header": {
@@ -195,13 +202,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",
@@ -325,7 +326,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",
@@ -430,6 +431,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",
@@ -586,7 +589,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": {
@@ -670,7 +673,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",
@@ -1016,6 +1023,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",
@@ -1033,13 +1052,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": {
@@ -1532,12 +1615,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",
@@ -1724,6 +1810,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",

View File

@@ -145,6 +145,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 +187,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 +202,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",
@@ -325,7 +326,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",
@@ -430,6 +431,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",
@@ -586,7 +589,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": {
@@ -670,7 +673,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",
@@ -1016,6 +1023,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",
@@ -1033,13 +1052,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": {
@@ -1532,12 +1615,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",
@@ -1724,6 +1810,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",

View File

@@ -145,6 +145,10 @@
}, },
"usage": { "usage": {
"timesUsed": "מספר שימושים" "timesUsed": "מספר שימושים"
},
"footer": {
"versionCount": "{count} גרסאות",
"viewAllVersions": "הצג את כל הגרסאות המקומיות"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +187,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "ניהול מודלים מוחרגים" "label": "ניהול מודלים מוחרגים"
},
"groupByModel": {
"label": "קיבוץ לפי דגם"
} }
}, },
"header": { "header": {
@@ -195,13 +202,7 @@
"statistics": "סטטיסטיקה" "statistics": "סטטיסטיקה"
}, },
"search": { "search": {
"placeholder": פש...", "placeholder": יפוש",
"placeholders": {
"loras": "חפש LoRAs...",
"recipes": "חפש מתכונים...",
"checkpoints": "חפש checkpoints...",
"embeddings": "חפש embeddings..."
},
"options": "אפשרויות חיפוש", "options": "אפשרויות חיפוש",
"searchIn": "חפש ב:", "searchIn": "חפש ב:",
"notAvailable": "חיפוש לא זמין בדף הסטטיסטיקה", "notAvailable": "חיפוש לא זמין בדף הסטטיסטיקה",
@@ -325,7 +326,7 @@
"extraFolderPaths": "נתיבי תיקיות נוספים", "extraFolderPaths": "נתיבי תיקיות נוספים",
"downloadPathTemplates": "תבניות נתיב הורדה", "downloadPathTemplates": "תבניות נתיב הורדה",
"priorityTags": "תגיות עדיפות", "priorityTags": "תגיות עדיפות",
"updateFlags": "תגי עדכון", "versionScope": "תגי עדכון",
"exampleImages": "תמונות דוגמה", "exampleImages": "תמונות דוגמה",
"autoOrganize": "ארגון אוטומטי", "autoOrganize": "ארגון אוטומטי",
"metadata": "מטא-נתונים", "metadata": "מטא-נתונים",
@@ -430,6 +431,8 @@
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה." "help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "קיבוץ לפי דגם",
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
"displayDensity": "צפיפות תצוגה", "displayDensity": "צפיפות תצוגה",
"displayDensityOptions": { "displayDensityOptions": {
"default": "ברירת מחדל", "default": "ברירת מחדל",
@@ -586,7 +589,7 @@
"download": "הורד", "download": "הורד",
"restartRequired": "דורש הפעלה מחדש" "restartRequired": "דורש הפעלה מחדש"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "אסטרטגיית תגי עדכון", "label": "אסטרטגיית תגי עדכון",
"help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.", "help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.",
"options": { "options": {
@@ -670,7 +673,11 @@
"sizeAsc": "הקטן ביותר", "sizeAsc": "הקטן ביותר",
"usage": "מספר שימושים", "usage": "מספר שימושים",
"usageDesc": "הכי הרבה", "usageDesc": "הכי הרבה",
"usageAsc": "הכי פחות" "usageAsc": "הכי פחות",
"versionsCount": "גרסאות מקומיות",
"versionsCountDesc": "הכי הרבה גרסאות ראשונות",
"versionsCountAsc": "הכי מעט גרסאות ראשונות",
"versionIdDesc": "גרסה חדשה ביותר ראשונה"
}, },
"refresh": { "refresh": {
"title": "רענן רשימת מודלים", "title": "רענן רשימת מודלים",
@@ -1016,6 +1023,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 הנפוצים ביותר",
@@ -1033,13 +1052,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": {
@@ -1532,12 +1615,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": "בחר צומת יעד",
@@ -1724,6 +1810,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",

View File

@@ -145,6 +145,10 @@
}, },
"usage": { "usage": {
"timesUsed": "使用回数" "timesUsed": "使用回数"
},
"footer": {
"versionCount": "{count} バージョン",
"viewAllVersions": "ローカルの全バージョンを表示"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +187,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "除外モデルを管理" "label": "除外モデルを管理"
},
"groupByModel": {
"label": "モデルでグループ化"
} }
}, },
"header": { "header": {
@@ -195,13 +202,7 @@
"statistics": "統計" "statistics": "統計"
}, },
"search": { "search": {
"placeholder": "検索...", "placeholder": "検索",
"placeholders": {
"loras": "LoRAを検索...",
"recipes": "レシピを検索...",
"checkpoints": "checkpointを検索...",
"embeddings": "embeddingを検索..."
},
"options": "検索オプション", "options": "検索オプション",
"searchIn": "検索対象:", "searchIn": "検索対象:",
"notAvailable": "統計ページでは検索は利用できません", "notAvailable": "統計ページでは検索は利用できません",
@@ -325,7 +326,7 @@
"extraFolderPaths": "追加フォルダーパス", "extraFolderPaths": "追加フォルダーパス",
"downloadPathTemplates": "ダウンロードパステンプレート", "downloadPathTemplates": "ダウンロードパステンプレート",
"priorityTags": "優先タグ", "priorityTags": "優先タグ",
"updateFlags": "アップデートフラグ", "versionScope": "アップデートフラグ",
"exampleImages": "例画像", "exampleImages": "例画像",
"autoOrganize": "自動整理", "autoOrganize": "自動整理",
"metadata": "メタデータ", "metadata": "メタデータ",
@@ -430,6 +431,8 @@
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。" "help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "モデルでグループ化",
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
"displayDensity": "表示密度", "displayDensity": "表示密度",
"displayDensityOptions": { "displayDensityOptions": {
"default": "デフォルト", "default": "デフォルト",
@@ -586,7 +589,7 @@
"download": "ダウンロード", "download": "ダウンロード",
"restartRequired": "再起動が必要" "restartRequired": "再起動が必要"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "アップデートフラグの表示戦略", "label": "アップデートフラグの表示戦略",
"help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。", "help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。",
"options": { "options": {
@@ -670,7 +673,11 @@
"sizeAsc": "小さい順", "sizeAsc": "小さい順",
"usage": "使用回数", "usage": "使用回数",
"usageDesc": "多い", "usageDesc": "多い",
"usageAsc": "少ない" "usageAsc": "少ない",
"versionsCount": "ローカルバージョン数",
"versionsCountDesc": "バージョン数の多い順",
"versionsCountAsc": "バージョン数の少ない順",
"versionIdDesc": "最新バージョン順"
}, },
"refresh": { "refresh": {
"title": "モデルリストを更新", "title": "モデルリストを更新",
@@ -1016,6 +1023,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",
@@ -1033,13 +1052,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": {
@@ -1532,12 +1615,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": "ターゲットノードを選択",
@@ -1724,6 +1810,7 @@
"enterLoraName": "LoRA名または構文を入力してください", "enterLoraName": "LoRA名または構文を入力してください",
"reconnectedSuccessfully": "LoRAが正常に再接続されました", "reconnectedSuccessfully": "LoRAが正常に再接続されました",
"reconnectFailed": "LoRA再接続エラー{message}", "reconnectFailed": "LoRA再接続エラー{message}",
"noPromptToSend": "送信するプロンプトがありません",
"cannotSend": "レシピを送信できませんレシピIDがありません", "cannotSend": "レシピを送信できませんレシピIDがありません",
"sendFailed": "レシピのワークフローへの送信に失敗しました", "sendFailed": "レシピのワークフローへの送信に失敗しました",
"sendError": "レシピのワークフロー送信エラー", "sendError": "レシピのワークフロー送信エラー",

View File

@@ -145,6 +145,10 @@
}, },
"usage": { "usage": {
"timesUsed": "사용 횟수" "timesUsed": "사용 횟수"
},
"footer": {
"versionCount": "{count}개 버전",
"viewAllVersions": "모든 로컬 버전 보기"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +187,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "제외된 모델 관리" "label": "제외된 모델 관리"
},
"groupByModel": {
"label": "모델별 그룹화"
} }
}, },
"header": { "header": {
@@ -195,13 +202,7 @@
"statistics": "통계" "statistics": "통계"
}, },
"search": { "search": {
"placeholder": "검색...", "placeholder": "검색",
"placeholders": {
"loras": "LoRA 검색...",
"recipes": "레시피 검색...",
"checkpoints": "Checkpoint 검색...",
"embeddings": "Embedding 검색..."
},
"options": "검색 옵션", "options": "검색 옵션",
"searchIn": "검색 범위:", "searchIn": "검색 범위:",
"notAvailable": "통계 페이지에서는 검색을 사용할 수 없습니다", "notAvailable": "통계 페이지에서는 검색을 사용할 수 없습니다",
@@ -325,7 +326,7 @@
"extraFolderPaths": "추가 폴다 경로", "extraFolderPaths": "추가 폴다 경로",
"downloadPathTemplates": "다운로드 경로 템플릿", "downloadPathTemplates": "다운로드 경로 템플릿",
"priorityTags": "우선순위 태그", "priorityTags": "우선순위 태그",
"updateFlags": "업데이트 표시", "versionScope": "업데이트 표시",
"exampleImages": "예시 이미지", "exampleImages": "예시 이미지",
"autoOrganize": "자동 정리", "autoOrganize": "자동 정리",
"metadata": "메타데이터", "metadata": "메타데이터",
@@ -430,6 +431,8 @@
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다." "help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "모델별 그룹화",
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
"displayDensity": "표시 밀도", "displayDensity": "표시 밀도",
"displayDensityOptions": { "displayDensityOptions": {
"default": "기본", "default": "기본",
@@ -586,7 +589,7 @@
"download": "다운로드", "download": "다운로드",
"restartRequired": "재시작 필요" "restartRequired": "재시작 필요"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "업데이트 표시 전략", "label": "업데이트 표시 전략",
"help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.", "help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.",
"options": { "options": {
@@ -670,7 +673,11 @@
"sizeAsc": "작은 순서", "sizeAsc": "작은 순서",
"usage": "사용 횟수", "usage": "사용 횟수",
"usageDesc": "많은 순", "usageDesc": "많은 순",
"usageAsc": "적은 순" "usageAsc": "적은 순",
"versionsCount": "로컬 버전 수",
"versionsCountDesc": "버전 수 많은 순",
"versionsCountAsc": "버전 수 적은 순",
"versionIdDesc": "최신 버전순"
}, },
"refresh": { "refresh": {
"title": "모델 목록 새로고침", "title": "모델 목록 새로고침",
@@ -1016,6 +1023,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",
@@ -1033,13 +1052,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": {
@@ -1532,12 +1615,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": "대상 노드 선택",
@@ -1724,6 +1810,7 @@
"enterLoraName": "LoRA 이름 또는 문법을 입력해주세요", "enterLoraName": "LoRA 이름 또는 문법을 입력해주세요",
"reconnectedSuccessfully": "LoRA가 성공적으로 다시 연결되었습니다", "reconnectedSuccessfully": "LoRA가 성공적으로 다시 연결되었습니다",
"reconnectFailed": "LoRA 다시 연결 오류: {message}", "reconnectFailed": "LoRA 다시 연결 오류: {message}",
"noPromptToSend": "보낼 프롬프트가 없습니다",
"cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락", "cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락",
"sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다", "sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다",
"sendError": "레시피를 워크플로로 전송하는 중 오류", "sendError": "레시피를 워크플로로 전송하는 중 오류",

View File

@@ -145,6 +145,10 @@
}, },
"usage": { "usage": {
"timesUsed": "Количество использований" "timesUsed": "Количество использований"
},
"footer": {
"versionCount": "{count} версий",
"viewAllVersions": "Показать все локальные версии"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +187,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Управление исключёнными моделями" "label": "Управление исключёнными моделями"
},
"groupByModel": {
"label": "Группировать по модели"
} }
}, },
"header": { "header": {
@@ -195,13 +202,7 @@
"statistics": "Статистика" "statistics": "Статистика"
}, },
"search": { "search": {
"placeholder": "Поиск...", "placeholder": "Поиск",
"placeholders": {
"loras": "Поиск LoRAs...",
"recipes": "Поиск рецептов...",
"checkpoints": "Поиск checkpoints...",
"embeddings": "Поиск embeddings..."
},
"options": "Опции поиска", "options": "Опции поиска",
"searchIn": "Искать в:", "searchIn": "Искать в:",
"notAvailable": "Поиск недоступен на странице статистики", "notAvailable": "Поиск недоступен на странице статистики",
@@ -325,7 +326,7 @@
"extraFolderPaths": "Дополнительные пути к папкам", "extraFolderPaths": "Дополнительные пути к папкам",
"downloadPathTemplates": "Шаблоны путей загрузки", "downloadPathTemplates": "Шаблоны путей загрузки",
"priorityTags": "Приоритетные теги", "priorityTags": "Приоритетные теги",
"updateFlags": "Метки обновлений", "versionScope": "Метки обновлений",
"exampleImages": "Примеры изображений", "exampleImages": "Примеры изображений",
"autoOrganize": "Автоорганизация", "autoOrganize": "Автоорганизация",
"metadata": "Метаданные", "metadata": "Метаданные",
@@ -430,6 +431,8 @@
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки." "help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "Группировать по модели",
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
"displayDensity": "Плотность отображения", "displayDensity": "Плотность отображения",
"displayDensityOptions": { "displayDensityOptions": {
"default": "По умолчанию", "default": "По умолчанию",
@@ -586,7 +589,7 @@
"download": "Загрузить", "download": "Загрузить",
"restartRequired": "Требует перезапуска" "restartRequired": "Требует перезапуска"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "Стратегия меток обновлений", "label": "Стратегия меток обновлений",
"help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.", "help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.",
"options": { "options": {
@@ -670,7 +673,11 @@
"sizeAsc": "Наименьшим", "sizeAsc": "Наименьшим",
"usage": "Число использований", "usage": "Число использований",
"usageDesc": "Больше", "usageDesc": "Больше",
"usageAsc": "Меньше" "usageAsc": "Меньше",
"versionsCount": "Локальные версии",
"versionsCountDesc": "Сначала больше версий",
"versionsCountAsc": "Сначала меньше версий",
"versionIdDesc": "Сначала новые версии"
}, },
"refresh": { "refresh": {
"title": "Обновить список моделей", "title": "Обновить список моделей",
@@ -1016,6 +1023,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",
@@ -1033,13 +1052,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": {
@@ -1532,12 +1615,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": "Выберите целевой узел",
@@ -1724,6 +1810,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",

View File

@@ -145,6 +145,10 @@
}, },
"usage": { "usage": {
"timesUsed": "使用次数" "timesUsed": "使用次数"
},
"footer": {
"versionCount": "{count} 个版本",
"viewAllVersions": "查看所有本地版本"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +187,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "管理已排除的模型" "label": "管理已排除的模型"
},
"groupByModel": {
"label": "按模型分组"
} }
}, },
"header": { "header": {
@@ -195,13 +202,7 @@
"statistics": "统计" "statistics": "统计"
}, },
"search": { "search": {
"placeholder": "搜索...", "placeholder": "搜索",
"placeholders": {
"loras": "搜索 LoRA...",
"recipes": "搜索配方...",
"checkpoints": "搜索 Checkpoint...",
"embeddings": "搜索 Embedding..."
},
"options": "搜索选项", "options": "搜索选项",
"searchIn": "搜索范围:", "searchIn": "搜索范围:",
"notAvailable": "统计页面不可用搜索", "notAvailable": "统计页面不可用搜索",
@@ -325,7 +326,7 @@
"extraFolderPaths": "额外文件夹路径", "extraFolderPaths": "额外文件夹路径",
"downloadPathTemplates": "下载路径模板", "downloadPathTemplates": "下载路径模板",
"priorityTags": "优先标签", "priorityTags": "优先标签",
"updateFlags": "更新标记", "versionScope": "版本范围",
"exampleImages": "示例图片", "exampleImages": "示例图片",
"autoOrganize": "自动整理", "autoOrganize": "自动整理",
"metadata": "元数据", "metadata": "元数据",
@@ -430,6 +431,8 @@
"help": "启用后如果下载历史服务记录显示该版本已下载LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。" "help": "启用后如果下载历史服务记录显示该版本已下载LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "按模型分组",
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
"displayDensity": "显示密度", "displayDensity": "显示密度",
"displayDensityOptions": { "displayDensityOptions": {
"default": "默认", "default": "默认",
@@ -586,12 +589,12 @@
"download": "下载", "download": "下载",
"restartRequired": "需要重启" "restartRequired": "需要重启"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "更新标记策略", "label": "版本分组",
"help": "决定更新徽章是否仅在新版本与本地文件共享相同基础模型时显示,或只要该模型有任何更新版本就显示。", "help": "控制版本在 UI 中的分组方式:按基础模型分组或合并显示。同时影响更新徽章逻辑和版本列表的筛选行为。",
"options": { "options": {
"sameBase": "按基础模型匹配更新", "sameBase": "按基础模型分组",
"any": "显示任何可用更新" "any": "显示所有版本"
} }
}, },
"hideEarlyAccessUpdates": { "hideEarlyAccessUpdates": {
@@ -670,7 +673,11 @@
"sizeAsc": "最小", "sizeAsc": "最小",
"usage": "使用次数", "usage": "使用次数",
"usageDesc": "最多", "usageDesc": "最多",
"usageAsc": "最少" "usageAsc": "最少",
"versionsCount": "本地版本数",
"versionsCountDesc": "版本数从多到少",
"versionsCountAsc": "版本数从少到多",
"versionIdDesc": "最新版本优先"
}, },
"refresh": { "refresh": {
"title": "刷新模型列表", "title": "刷新模型列表",
@@ -1016,6 +1023,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",
@@ -1033,13 +1052,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": {
@@ -1532,12 +1615,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": "选择目标节点",
@@ -1724,6 +1810,7 @@
"enterLoraName": "请输入 LoRA 名称或语法", "enterLoraName": "请输入 LoRA 名称或语法",
"reconnectedSuccessfully": "LoRA 重新连接成功", "reconnectedSuccessfully": "LoRA 重新连接成功",
"reconnectFailed": "LoRA 重新连接出错:{message}", "reconnectFailed": "LoRA 重新连接出错:{message}",
"noPromptToSend": "没有可发送的提示词",
"cannotSend": "无法发送配方:缺少配方 ID", "cannotSend": "无法发送配方:缺少配方 ID",
"sendFailed": "发送配方到工作流失败", "sendFailed": "发送配方到工作流失败",
"sendError": "发送配方到工作流出错", "sendError": "发送配方到工作流出错",

View File

@@ -145,6 +145,10 @@
}, },
"usage": { "usage": {
"timesUsed": "使用次數" "timesUsed": "使用次數"
},
"footer": {
"versionCount": "{count} 個版本",
"viewAllVersions": "檢視所有本地版本"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -183,6 +187,9 @@
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "管理已排除的模型" "label": "管理已排除的模型"
},
"groupByModel": {
"label": "按模型分組"
} }
}, },
"header": { "header": {
@@ -195,13 +202,7 @@
"statistics": "統計" "statistics": "統計"
}, },
"search": { "search": {
"placeholder": "搜尋...", "placeholder": "搜尋",
"placeholders": {
"loras": "搜尋 LoRA...",
"recipes": "搜尋配方...",
"checkpoints": "搜尋 checkpoint...",
"embeddings": "搜尋 embedding..."
},
"options": "搜尋選項", "options": "搜尋選項",
"searchIn": "搜尋範圍:", "searchIn": "搜尋範圍:",
"notAvailable": "統計頁面無法搜尋", "notAvailable": "統計頁面無法搜尋",
@@ -325,7 +326,7 @@
"extraFolderPaths": "額外資料夾路徑", "extraFolderPaths": "額外資料夾路徑",
"downloadPathTemplates": "下載路徑範本", "downloadPathTemplates": "下載路徑範本",
"priorityTags": "優先標籤", "priorityTags": "優先標籤",
"updateFlags": "更新標記", "versionScope": "版本範圍",
"exampleImages": "範例圖片", "exampleImages": "範例圖片",
"autoOrganize": "自動整理", "autoOrganize": "自動整理",
"metadata": "中繼資料", "metadata": "中繼資料",
@@ -430,6 +431,8 @@
"help": "啟用後如果下載歷史服務記錄顯示該版本已下載LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。" "help": "啟用後如果下載歷史服務記錄顯示該版本已下載LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
}, },
"layoutSettings": { "layoutSettings": {
"groupByModel": "按模型分組",
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
"displayDensity": "顯示密度", "displayDensity": "顯示密度",
"displayDensityOptions": { "displayDensityOptions": {
"default": "預設", "default": "預設",
@@ -586,7 +589,7 @@
"download": "下載", "download": "下載",
"restartRequired": "需要重新啟動" "restartRequired": "需要重新啟動"
}, },
"updateFlagStrategy": { "versionGrouping": {
"label": "更新標記策略", "label": "更新標記策略",
"help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。", "help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。",
"options": { "options": {
@@ -670,7 +673,11 @@
"sizeAsc": "最小", "sizeAsc": "最小",
"usage": "使用次數", "usage": "使用次數",
"usageDesc": "最多", "usageDesc": "最多",
"usageAsc": "最少" "usageAsc": "最少",
"versionsCount": "本地版本數",
"versionsCountDesc": "版本數從多到少",
"versionsCountAsc": "版本數從少到多",
"versionIdDesc": "最新版本優先"
}, },
"refresh": { "refresh": {
"title": "重新整理模型列表", "title": "重新整理模型列表",
@@ -1016,6 +1023,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",
@@ -1033,13 +1052,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": {
@@ -1532,12 +1615,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": "選擇目標節點",
@@ -1724,6 +1810,7 @@
"enterLoraName": "請輸入 LoRA 名稱或語法", "enterLoraName": "請輸入 LoRA 名稱或語法",
"reconnectedSuccessfully": "LoRA 重新連結成功", "reconnectedSuccessfully": "LoRA 重新連結成功",
"reconnectFailed": "LoRA 重新連結錯誤:{message}", "reconnectFailed": "LoRA 重新連結錯誤:{message}",
"noPromptToSend": "沒有可發送的提示詞",
"cannotSend": "無法傳送配方:缺少配方 ID", "cannotSend": "無法傳送配方:缺少配方 ID",
"sendFailed": "傳送配方到工作流失敗", "sendFailed": "傳送配方到工作流失敗",
"sendError": "傳送配方到工作流錯誤", "sendError": "傳送配方到工作流錯誤",

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

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

View File

@@ -49,7 +49,10 @@ from ...utils.constants import (
VALID_LORA_TYPES, VALID_LORA_TYPES,
) )
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
@@ -532,6 +535,7 @@ 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)) logger.debug("Registered %s nodes in registry", len(nodes))
self._registry_updated.set() self._registry_updated.set()
@@ -1498,6 +1502,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
@@ -3091,13 +3105,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,
) )
@@ -3136,12 +3154,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)

View File

@@ -233,6 +233,8 @@ 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()
@@ -366,6 +368,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 +404,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),
} }

View File

@@ -477,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:
@@ -487,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:
@@ -497,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
@@ -510,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({

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:

View File

@@ -48,6 +48,7 @@ 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"),
} }
def find_duplicate_hashes(self) -> Dict: def find_duplicate_hashes(self) -> Dict:

View File

@@ -48,6 +48,7 @@ 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"),
} }
def find_duplicate_hashes(self) -> Dict: def find_duplicate_hashes(self) -> Dict:

View File

@@ -59,6 +59,7 @@ 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"),
} }
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]: async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:

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

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)

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"))

View File

@@ -98,7 +98,7 @@ 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,
@@ -106,6 +106,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"backup_auto_enabled": True, "backup_auto_enabled": True,
"backup_retention_count": 5, "backup_retention_count": 5,
"use_new_license_icons": True, "use_new_license_icons": True,
"group_by_model": False,
} }
@@ -744,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

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

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."""

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.5"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

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 {

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 {

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 */

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,

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;
} }

View File

@@ -264,6 +264,23 @@
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;
}
/* Ensure hidden class works properly */ /* Ensure hidden class works properly */
.hidden { .hidden {
display: none !important; display: none !important;

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 ---------- */

View File

@@ -1,7 +1,7 @@
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,
@@ -115,7 +115,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) {
@@ -154,7 +157,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();
});
}
} }
} }
@@ -1271,6 +1281,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]) => {
@@ -1352,6 +1368,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');

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');

View File

@@ -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) {

View File

@@ -338,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');
@@ -353,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);
} }

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() {

View File

@@ -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;
}
} }

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
*/ */

View File

@@ -1,6 +1,6 @@
// 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';
@@ -129,6 +129,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();
} }
@@ -459,15 +462,220 @@ 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.
* Saves current sort when entering grouped mode, restores normal sort
* when leaving — prevents "Most versions first" persisting after exit.
*/
onGroupByModelToggled(isEnabled) {
const normalKey = `${this.pageType}_sort_normal`;
const groupedKey = `${this.pageType}_sort_grouped`;
if (isEnabled) {
// Entering group mode: save current sort for later restoration
setStorageItem(normalKey, this.pageState.sortBy);
// Restore previously saved grouped sort, if any
const savedGroupedSort = getStorageItem(groupedKey);
if (savedGroupedSort) {
this.pageState.sortBy = savedGroupedSort;
this.saveSortPreference(savedGroupedSort);
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = savedGroupedSort;
}
}
} else {
// Leaving group mode: save current grouped sort aside, restore normal
const currentSort = this.pageState.sortBy;
if (currentSort && currentSort.startsWith('versions_count')) {
setStorageItem(groupedKey, currentSort);
}
const savedNormalSort = getStorageItem(normalKey);
if (savedNormalSort) {
removeStorageItem(normalKey);
this.pageState.sortBy = savedNormalSort;
this.saveSortPreference(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;
} }
try { try {
await this.api.clearCustomFilter(); await this.api.clearCustomFilter();
} catch (error) { } catch (error) {
@@ -475,6 +683,14 @@ export class PageControls {
showToast('toast.controls.clearFilterFailed', { message: error.message }, 'error'); showToast('toast.controls.clearFilterFailed', { message: error.message }, 'error');
} }
} }
/**
* Truncate text with ellipsis
*/
_truncateText(text, maxLength) {
if (!text) return '';
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
}
/** /**
* Initialize the favorites filter button state * Initialize the favorites filter button state

View File

@@ -100,6 +100,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 +271,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();
@@ -448,6 +470,10 @@ export function createModelCard(model, modelType) {
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();
@@ -659,16 +685,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>` : ''}

View File

@@ -752,6 +752,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);

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;
setActiveModelTypeKey(modelType); if (tagEditOptions.showSuggestions) {
ensurePriorityTagSuggestions(); setActiveModelTypeKey(modelType);
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
const tagForm = editContainer.querySelector('.metadata-add-form'); if (tagEditOptions.showSuggestions) {
const suggestionsDropdown = createSuggestionsDropdown(originalTags); const tagForm = editContainer.querySelector('.metadata-add-form');
tagForm.appendChild(suggestionsDropdown); const suggestionsDropdown = createSuggestionsDropdown(originalTags);
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,59 +338,68 @@ async function saveTags() {
} }
try { try {
// Save tags metadata // Use custom save handler if provided, otherwise default model API
await getModelApiClient().saveModelMetadata(filePath, { tags: tags }); if (tagEditOptions.saveHandler) {
await tagEditOptions.saveHandler(filePath, tags);
} else {
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";
// Update the compact tags display // Use custom onSaved if provided (e.g. for recipe dirty state + re-render)
const compactTagsContainer = document.querySelector('.model-tags-container'); if (tagEditOptions.onSaved) {
if (compactTagsContainer) { tagEditOptions.onSaved(tags);
// Generate new compact tags HTML } else {
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact'); // Update the compact tags display
const compactTagsContainer = scope;
if (compactTagsDisplay) { if (compactTagsContainer) {
// Clear current tags // Generate new compact tags HTML
compactTagsDisplay.innerHTML = ''; const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
// Add visible tags (up to 5) if (compactTagsDisplay) {
const visibleTags = tags.slice(0, 5); // Clear current tags
visibleTags.forEach(tag => { compactTagsDisplay.innerHTML = '';
const span = document.createElement('span');
span.className = 'model-tag-compact'; // Add visible tags (up to 5)
span.textContent = tag; const visibleTags = tags.slice(0, 5);
compactTagsDisplay.appendChild(span); visibleTags.forEach(tag => {
}); const span = document.createElement('span');
span.className = 'model-tag-compact';
span.textContent = tag;
compactTagsDisplay.appendChild(span);
});
// Add more indicator if needed
const remainingCount = Math.max(0, tags.length - 5);
if (remainingCount > 0) {
const more = document.createElement('span');
more.className = 'model-tag-more';
more.dataset.count = remainingCount;
more.textContent = `+${remainingCount}`;
compactTagsDisplay.appendChild(more);
}
}
// Add more indicator if needed // Update tooltip content
const remainingCount = Math.max(0, tags.length - 5); const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
if (remainingCount > 0) { if (tooltipContent) {
const more = document.createElement('span'); tooltipContent.innerHTML = '';
more.className = 'model-tag-more';
more.dataset.count = remainingCount; tags.forEach(tag => {
more.textContent = `+${remainingCount}`; const span = document.createElement('span');
compactTagsDisplay.appendChild(more); span.className = 'tooltip-tag';
span.textContent = tag;
tooltipContent.appendChild(span);
});
} }
} }
// Update tooltip content // Exit edit mode
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content'); editBtn.click();
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const span = document.createElement('span');
span.className = 'tooltip-tag';
span.textContent = tag;
tooltipContent.appendChild(span);
});
}
} }
// Exit edit mode
editBtn.click();
showToast('modelTags.messages.updated', {}, 'success'); showToast('modelTags.messages.updated', {}, 'success');
} catch (error) { } catch (error) {
console.error('Error saving tags:', error); console.error('Error saving tags:', 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();

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -28,14 +28,24 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
if (hasParams) { if (hasParams) {
content += ` content += `
<div class="params-tags"> <div class="metadata-row params-row">
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''} <div class="param-header">
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''} <span class="metadata-label">Params:</span>
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''} <div class="param-actions">
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''} <button class="send-params-btn" title="Send Params to Workflow">
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''} <i class="fas fa-paper-plane"></i>
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''} </button>
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''} </div>
</div>
<div class="params-tags">
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
</div>
</div> </div>
`; `;
} }
@@ -53,12 +63,19 @@ 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">
<span class="metadata-label">Prompt:</span> <div class="param-header">
<span class="metadata-label">Prompt:</span>
<div class="param-actions">
<button class="send-prompt-btn" data-prompt-index="${promptIndex}" title="Send Prompt to Workflow">
<i class="fas fa-paper-plane"></i>
</button>
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}" title="Copy Prompt">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="metadata-prompt-wrapper"> <div class="metadata-prompt-wrapper">
<div class="metadata-prompt">${prompt}</div> <div class="metadata-prompt">${prompt}</div>
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </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,12 +86,19 @@ 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">
<span class="metadata-label">Negative Prompt:</span> <div class="param-header">
<span class="metadata-label">Negative Prompt:</span>
<div class="param-actions">
<button class="send-prompt-btn" data-prompt-index="${negPromptIndex}" title="Send Negative Prompt to Workflow">
<i class="fas fa-paper-plane"></i>
</button>
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}" title="Copy Negative Prompt">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="metadata-prompt-wrapper"> <div class="metadata-prompt-wrapper">
<div class="metadata-prompt">${negativePrompt}</div> <div class="metadata-prompt">${negativePrompt}</div>
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </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>

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', () => {

View File

@@ -27,6 +27,9 @@ export class SearchManager {
// Create clear button for search input // Create clear button for search input
this.createClearButton(); this.createClearButton();
// Keyboard shortcut cue element (static, exists in the HTML)
this.searchShortcutCue = document.getElementById('searchShortcutCue');
this.initEventListeners(); this.initEventListeners();
this.loadSearchPreferences(); this.loadSearchPreferences();
this.setupKeyboardShortcuts(); this.setupKeyboardShortcuts();
@@ -163,8 +166,13 @@ export class SearchManager {
} }
updateClearButtonVisibility() { updateClearButtonVisibility() {
const hasText = this.searchInput.value.length > 0;
if (this.clearButton) { if (this.clearButton) {
this.clearButton.classList.toggle('visible', this.searchInput.value.length > 0); this.clearButton.classList.toggle('visible', hasText);
}
// Toggle the keyboard shortcut cue: visible only when search is empty
if (this.searchShortcutCue) {
this.searchShortcutCue.classList.toggle('hidden', hasText);
} }
} }

View File

@@ -905,15 +905,21 @@ export class SettingsManager {
showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false; showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false;
} }
// Set group by model
const groupByModelCheckbox = document.getElementById('groupByModel');
if (groupByModelCheckbox) {
groupByModelCheckbox.checked = !!state.global.settings.group_by_model;
}
// Set model name display setting // Set model name display setting
const modelNameDisplaySelect = document.getElementById('modelNameDisplay'); const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
if (modelNameDisplaySelect) { if (modelNameDisplaySelect) {
modelNameDisplaySelect.value = state.global.settings.model_name_display || 'model_name'; modelNameDisplaySelect.value = state.global.settings.model_name_display || 'model_name';
} }
const updateFlagStrategySelect = document.getElementById('updateFlagStrategy'); const versionGroupingSelect = document.getElementById('versionGrouping');
if (updateFlagStrategySelect) { if (versionGroupingSelect) {
updateFlagStrategySelect.value = state.global.settings.update_flag_strategy || 'same_base'; versionGroupingSelect.value = state.global.settings.version_grouping || 'same_base';
} }
// Set hide early access updates setting // Set hide early access updates setting
@@ -2011,7 +2017,11 @@ export class SettingsManager {
} }
} }
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content') { if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content' || settingKey === 'group_by_model') {
// Save/restore sort preference when toggling group_by_model
if (settingKey === 'group_by_model' && window.pageControls?.onGroupByModelToggled) {
window.pageControls.onGroupByModelToggled(value);
}
this.reloadContent(); this.reloadContent();
} }
@@ -2060,7 +2070,7 @@ export class SettingsManager {
if ( if (
settingKey === 'model_name_display' settingKey === 'model_name_display'
|| settingKey === 'model_card_footer_action' || settingKey === 'model_card_footer_action'
|| settingKey === 'update_flag_strategy' || settingKey === 'version_grouping'
|| settingKey === 'mature_blur_level' || settingKey === 'mature_blur_level'
) { ) {
this.reloadContent(); this.reloadContent();
@@ -3046,6 +3056,10 @@ export class SettingsManager {
const useNewLicenseIcons = state.global.settings.use_new_license_icons !== false; const useNewLicenseIcons = state.global.settings.use_new_license_icons !== false;
document.body.classList.toggle('use-new-license-icons', useNewLicenseIcons); document.body.classList.toggle('use-new-license-icons', useNewLicenseIcons);
// Apply group-by-model mode
const groupByModel = !!state.global.settings.group_by_model;
document.body.classList.toggle('group-by-model', groupByModel);
} }
} }

View File

@@ -149,9 +149,10 @@ class RecipeManager {
_showCustomFilterIndicator() { _showCustomFilterIndicator() {
const indicator = document.getElementById('customFilterIndicator'); const indicator = document.getElementById('customFilterIndicator');
const textElement = document.getElementById('customFilterText'); if (!indicator) return;
const textElement = indicator.querySelector('.customFilterText');
if (!indicator || !textElement) return; if (!textElement) return;
// Update text based on filter type // Update text based on filter type
let filterText = ''; let filterText = '';
@@ -250,6 +251,11 @@ class RecipeManager {
bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode()); bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode());
} }
const duplicatesButton = document.querySelector('[data-action="find-duplicates"]');
if (duplicatesButton) {
duplicatesButton.addEventListener('click', () => this.findDuplicateRecipes());
}
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn'); const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
if (favoriteFilterBtn) { if (favoriteFilterBtn) {
favoriteFilterBtn.addEventListener('click', () => { favoriteFilterBtn.addEventListener('click', () => {

View File

@@ -44,7 +44,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
include_trigger_words: false, include_trigger_words: false,
compact_mode: false, compact_mode: false,
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
update_flag_strategy: 'same_base', version_grouping: 'same_base',
hide_early_access_updates: false, hide_early_access_updates: false,
auto_organize_exclusions: [], auto_organize_exclusions: [],
metadata_refresh_skip_paths: [], metadata_refresh_skip_paths: [],
@@ -54,6 +54,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
backup_retention_count: 5, backup_retention_count: 5,
strip_lora_on_copy: false, strip_lora_on_copy: false,
use_new_license_icons: true, use_new_license_icons: true,
group_by_model: false,
}); });
export function createDefaultSettings() { export function createDefaultSettings() {

View File

@@ -1,6 +1,8 @@
// Statistics page functionality // Statistics page functionality
import { appCore } from './core.js'; import { appCore } from './core.js';
import { showToast } from './utils/uiHelpers.js'; import { showToast } from './utils/uiHelpers.js';
import { translate } from './utils/i18nHelpers.js';
import { i18n } from './i18n/index.js';
// Chart.js import (assuming it's available globally or via CDN) // Chart.js import (assuming it's available globally or via CDN)
// If Chart.js isn't available, we'll need to add it to the project // If Chart.js isn't available, we'll need to add it to the project
@@ -124,43 +126,43 @@ export class StatisticsManager {
{ {
icon: 'fas fa-magic', icon: 'fas fa-magic',
value: this.data.collection.total_models, value: this.data.collection.total_models,
label: 'Total Models', label: translate('statistics.metrics.totalModels'),
format: 'number' format: 'number'
}, },
{ {
icon: 'fas fa-database', icon: 'fas fa-database',
value: this.data.collection.total_size, value: this.data.collection.total_size,
label: 'Total Storage', label: translate('statistics.metrics.totalStorage'),
format: 'size' format: 'size'
}, },
{ {
icon: 'fas fa-play-circle', icon: 'fas fa-play-circle',
value: this.data.collection.total_generations, value: this.data.collection.total_generations,
label: 'Total Generations', label: translate('statistics.metrics.totalGenerations'),
format: 'number' format: 'number'
}, },
{ {
icon: 'fas fa-chart-line', icon: 'fas fa-chart-line',
value: this.calculateUsageRate(), value: this.calculateUsageRate(),
label: 'Usage Rate', label: translate('statistics.metrics.usageRate'),
format: 'percentage' format: 'percentage'
}, },
{ {
icon: 'fas fa-layer-group', icon: 'fas fa-layer-group',
value: this.data.collection.lora_count, value: this.data.collection.lora_count,
label: 'LoRAs', label: translate('statistics.metrics.loras'),
format: 'number' format: 'number'
}, },
{ {
icon: 'fas fa-check-circle', icon: 'fas fa-check-circle',
value: this.data.collection.checkpoint_count, value: this.data.collection.checkpoint_count,
label: 'Checkpoints', label: translate('statistics.metrics.checkpoints'),
format: 'number' format: 'number'
}, },
{ {
icon: 'fas fa-code', icon: 'fas fa-code',
value: this.data.collection.embedding_count, value: this.data.collection.embedding_count,
label: 'Embeddings', label: translate('statistics.metrics.embeddings'),
format: 'number' format: 'number'
} }
]; ];
@@ -189,18 +191,14 @@ export class StatisticsManager {
case 'size': case 'size':
return this.formatFileSize(value); return this.formatFileSize(value);
case 'percentage': case 'percentage':
return `${value.toFixed(1)}%`; return new Intl.NumberFormat(i18n.getCurrentLocale(), { style: 'percent', maximumFractionDigits: 1 }).format(value / 100);
default: default:
return value; return value;
} }
} }
formatFileSize(bytes) { formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes'; return i18n.formatFileSize(bytes);
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
} }
calculateUsageRate() { calculateUsageRate() {
@@ -250,7 +248,7 @@ export class StatisticsManager {
if (!ctx || !this.data.collection) return; if (!ctx || !this.data.collection) return;
const data = { const data = {
labels: ['LoRAs', 'Checkpoints', 'Embeddings'], labels: [translate('statistics.metrics.loras'), translate('statistics.metrics.checkpoints'), translate('statistics.metrics.embeddings')],
datasets: [{ datasets: [{
data: [ data: [
this.data.collection.lora_count, this.data.collection.lora_count,
@@ -290,28 +288,28 @@ export class StatisticsManager {
const checkpointData = this.data.baseModels.checkpoints; const checkpointData = this.data.baseModels.checkpoints;
const embeddingData = this.data.baseModels.embeddings; const embeddingData = this.data.baseModels.embeddings;
const allModels = new Set([ const allModels = Array.from(new Set([
...Object.keys(loraData), ...Object.keys(loraData),
...Object.keys(checkpointData), ...Object.keys(checkpointData),
...Object.keys(embeddingData) ...Object.keys(embeddingData)
]); ])).sort();
const data = { const data = {
labels: Array.from(allModels), labels: allModels,
datasets: [ datasets: [
{ {
label: 'LoRAs', label: translate('statistics.metrics.loras'),
data: Array.from(allModels).map(model => loraData[model] || 0), data: allModels.map(model => loraData[model] || 0),
backgroundColor: 'oklch(68% 0.28 256 / 0.7)' backgroundColor: 'oklch(68% 0.28 256 / 0.7)'
}, },
{ {
label: 'Checkpoints', label: translate('statistics.metrics.checkpoints'),
data: Array.from(allModels).map(model => checkpointData[model] || 0), data: allModels.map(model => checkpointData[model] || 0),
backgroundColor: 'oklch(68% 0.28 200 / 0.7)' backgroundColor: 'oklch(68% 0.28 200 / 0.7)'
}, },
{ {
label: 'Embeddings', label: translate('statistics.metrics.embeddings'),
data: Array.from(allModels).map(model => embeddingData[model] || 0), data: allModels.map(model => embeddingData[model] || 0),
backgroundColor: 'oklch(68% 0.28 120 / 0.7)' backgroundColor: 'oklch(68% 0.28 120 / 0.7)'
} }
] ]
@@ -345,21 +343,21 @@ export class StatisticsManager {
labels: timeline.map(item => new Date(item.date).toLocaleDateString()), labels: timeline.map(item => new Date(item.date).toLocaleDateString()),
datasets: [ datasets: [
{ {
label: 'LoRA Usage', label: translate('statistics.charts.loraUsage'),
data: timeline.map(item => item.lora_usage), data: timeline.map(item => item.lora_usage),
borderColor: 'oklch(68% 0.28 256)', borderColor: 'oklch(68% 0.28 256)',
backgroundColor: 'oklch(68% 0.28 256 / 0.1)', backgroundColor: 'oklch(68% 0.28 256 / 0.1)',
fill: true fill: true
}, },
{ {
label: 'Checkpoint Usage', label: translate('statistics.charts.checkpointUsage'),
data: timeline.map(item => item.checkpoint_usage), data: timeline.map(item => item.checkpoint_usage),
borderColor: 'oklch(68% 0.28 200)', borderColor: 'oklch(68% 0.28 200)',
backgroundColor: 'oklch(68% 0.28 200 / 0.1)', backgroundColor: 'oklch(68% 0.28 200 / 0.1)',
fill: true fill: true
}, },
{ {
label: 'Embedding Usage', label: translate('statistics.charts.embeddingUsage'),
data: timeline.map(item => item.embedding_usage), data: timeline.map(item => item.embedding_usage),
borderColor: 'oklch(68% 0.28 120)', borderColor: 'oklch(68% 0.28 120)',
backgroundColor: 'oklch(68% 0.28 120 / 0.1)', backgroundColor: 'oklch(68% 0.28 120 / 0.1)',
@@ -383,14 +381,14 @@ export class StatisticsManager {
display: true, display: true,
title: { title: {
display: true, display: true,
text: 'Date' text: translate('statistics.charts.date')
} }
}, },
y: { y: {
display: true, display: true,
title: { title: {
display: true, display: true,
text: 'Usage Count' text: translate('statistics.charts.usageCount')
} }
} }
} }
@@ -416,7 +414,7 @@ export class StatisticsManager {
const data = { const data = {
labels: allModels.map(model => model.name), labels: allModels.map(model => model.name),
datasets: [{ datasets: [{
label: 'Usage Count', label: translate('statistics.charts.usageCount'),
data: allModels.map(model => model.usage_count), data: allModels.map(model => model.usage_count),
backgroundColor: allModels.map(model => { backgroundColor: allModels.map(model => {
switch(model.type) { switch(model.type) {
@@ -450,7 +448,7 @@ export class StatisticsManager {
if (!ctx || !this.data.collection) return; if (!ctx || !this.data.collection) return;
const data = { const data = {
labels: ['LoRAs', 'Checkpoints', 'Embeddings'], labels: [translate('statistics.metrics.loras'), translate('statistics.metrics.checkpoints'), translate('statistics.metrics.embeddings')],
datasets: [{ datasets: [{
data: [ data: [
this.data.collection.lora_size, this.data.collection.lora_size,
@@ -504,7 +502,7 @@ export class StatisticsManager {
const data = { const data = {
datasets: [{ datasets: [{
label: 'Models', label: translate('statistics.charts.models'),
data: allData.map(item => ({ data: allData.map(item => ({
x: item.size, x: item.size,
y: item.usage_count, y: item.usage_count,
@@ -532,14 +530,14 @@ export class StatisticsManager {
x: { x: {
title: { title: {
display: true, display: true,
text: 'File Size (bytes)' text: translate('statistics.charts.fileSizeBytes')
}, },
type: 'logarithmic' type: 'logarithmic'
}, },
y: { y: {
title: { title: {
display: true, display: true,
text: 'Usage Count' text: translate('statistics.charts.usageCount')
} }
} }
}, },
@@ -548,7 +546,7 @@ export class StatisticsManager {
callbacks: { callbacks: {
label: (context) => { label: (context) => {
const point = context.raw; const point = context.raw;
return `${point.name}: ${this.formatFileSize(point.x)}, ${point.y} uses`; return translate('statistics.tooltips.chartUsage', { name: point.name, size: this.formatFileSize(point.x), count: point.y });
} }
} }
} }
@@ -563,12 +561,12 @@ export class StatisticsManager {
const distribution = this.data.collection.model_types_distribution; const distribution = this.data.collection.model_types_distribution;
const typeDisplayNames = { const typeDisplayNames = {
lora: 'LoRA', lora: translate('statistics.modelTypes.lora'),
locon: 'LyCORIS', locon: translate('statistics.modelTypes.locon'),
dora: 'DoRA', dora: translate('statistics.modelTypes.dora'),
checkpoint: 'Checkpoint', checkpoint: translate('statistics.modelTypes.checkpoint'),
diffusion_model: 'Diffusion Model', diffusion_model: translate('statistics.modelTypes.diffusion_model'),
embedding: 'Embeddings' embedding: translate('statistics.modelTypes.embedding')
}; };
const colorPalette = { const colorPalette = {
@@ -610,7 +608,7 @@ export class StatisticsManager {
const total = context.dataset.data.reduce((a, b) => a + b, 0); const total = context.dataset.data.reduce((a, b) => a + b, 0);
const value = context.parsed; const value = context.parsed;
const pct = ((value / total) * 100).toFixed(1); const pct = ((value / total) * 100).toFixed(1);
return ` ${context.label}: ${value} (${pct}%)`; return translate('statistics.tooltips.chartPercentage', { label: context.label, value, pct });
} }
} }
} }
@@ -654,7 +652,7 @@ export class StatisticsManager {
// Show loading indicator on initial load // Show loading indicator on initial load
if (state.offset === 0) { if (state.offset === 0) {
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-spinner fa-spin"></i> Loading...</div>'; container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-spinner fa-spin"></i> ' + translate('statistics.placeholders.loading') + '</div>';
} }
try { try {
@@ -670,7 +668,7 @@ export class StatisticsManager {
} }
if (items.length === 0 && state.offset === 0) { if (items.length === 0 && state.offset === 0) {
container.innerHTML = '<div class="loading-placeholder">No models found</div>'; container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.noModels') + '</div>';
state.hasMore = false; state.hasMore = false;
} else if (items.length < state.limit) { } else if (items.length < state.limit) {
state.hasMore = false; state.hasMore = false;
@@ -683,7 +681,7 @@ export class StatisticsManager {
onerror="this.src='/loras_static/images/no-preview.png'"> onerror="this.src='/loras_static/images/no-preview.png'">
<div class="model-info"> <div class="model-info">
<div class="model-name" title="${model.name}">${model.name}</div> <div class="model-name" title="${model.name}">${model.name}</div>
<div class="model-meta">${model.base_model}${model.folder || 'Root'}</div> <div class="model-meta">${model.base_model}${model.folder || translate('statistics.placeholders.rootFolder')}</div>
</div> </div>
<div class="model-usage">${model.usage_count}</div> <div class="model-usage">${model.usage_count}</div>
</div> </div>
@@ -695,7 +693,7 @@ export class StatisticsManager {
} catch (error) { } catch (error) {
console.error(`Error loading ${type} list:`, error); console.error(`Error loading ${type} list:`, error);
if (state.offset === 0) { if (state.offset === 0) {
container.innerHTML = '<div class="loading-placeholder">Error loading data</div>'; container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.errorLoading') + '</div>';
} }
} finally { } finally {
state.isLoading = false; state.isLoading = false;
@@ -718,7 +716,7 @@ export class StatisticsManager {
].sort((a, b) => b.size - a.size).slice(0, 10); ].sort((a, b) => b.size - a.size).slice(0, 10);
if (allModels.length === 0) { if (allModels.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No storage data available</div>'; container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.noStorageData') + '</div>';
return; return;
} }
@@ -726,7 +724,7 @@ export class StatisticsManager {
<div class="model-item"> <div class="model-item">
<div class="model-info"> <div class="model-info">
<div class="model-name" title="${model.name}">${model.name}</div> <div class="model-name" title="${model.name}">${model.name}</div>
<div class="model-meta">${model.type}${model.base_model}</div> <div class="model-meta">${translate('statistics.modelTypes.' + model.type.toLowerCase())}${model.base_model}</div>
</div> </div>
<div class="model-usage">${this.formatFileSize(model.size)}</div> <div class="model-usage">${this.formatFileSize(model.size)}</div>
</div> </div>
@@ -744,7 +742,7 @@ export class StatisticsManager {
const size = Math.ceil((tagData.count / maxCount) * 5); const size = Math.ceil((tagData.count / maxCount) * 5);
return ` return `
<span class="tag-cloud-item size-${size}" <span class="tag-cloud-item size-${size}"
title="${tagData.tag}: ${tagData.count} models"> title="${translate('statistics.tooltips.tagCount', { tag: tagData.tag, count: tagData.count })}">
${tagData.tag} ${tagData.tag}
</span> </span>
`; `;
@@ -758,17 +756,30 @@ export class StatisticsManager {
const insights = this.data.insights.insights; const insights = this.data.insights.insights;
if (insights.length === 0) { if (insights.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No insights available</div>'; container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.insights.noInsights') + '</div>';
return; return;
} }
container.innerHTML = insights.map(insight => ` container.innerHTML = insights.map(insight => {
const params = insight.params || {};
let title, description, suggestion;
if (insight.key) {
title = translate('statistics.' + insight.key + '.title', params);
description = translate('statistics.' + insight.key + '.description', params);
suggestion = translate('statistics.' + insight.key + '.suggestion', params);
} else {
// Backward compatibility for insights without key/params
title = insight.title || '';
description = insight.description || '';
suggestion = insight.suggestion || '';
}
return `
<div class="insight-card type-${insight.type}"> <div class="insight-card type-${insight.type}">
<div class="insight-title">${insight.title}</div> <div class="insight-title">${title}</div>
<div class="insight-description">${insight.description}</div> <div class="insight-description">${description}</div>
<div class="insight-suggestion">${insight.suggestion}</div> <div class="insight-suggestion">${suggestion}</div>
</div> </div>
`).join(''); `}).join('');
// Render collection analysis cards // Render collection analysis cards
this.renderCollectionAnalysis(); this.renderCollectionAnalysis();
@@ -782,25 +793,25 @@ export class StatisticsManager {
{ {
icon: 'fas fa-percentage', icon: 'fas fa-percentage',
value: this.calculateUsageRate(), value: this.calculateUsageRate(),
label: 'Usage Rate', label: translate('statistics.metrics.usageRate'),
format: 'percentage' format: 'percentage'
}, },
{ {
icon: 'fas fa-tags', icon: 'fas fa-tags',
value: this.data.tags?.total_unique_tags || 0, value: this.data.tags?.total_unique_tags || 0,
label: 'Unique Tags', label: translate('statistics.metrics.uniqueTags'),
format: 'number' format: 'number'
}, },
{ {
icon: 'fas fa-clock', icon: 'fas fa-clock',
value: this.data.collection.unused_loras + this.data.collection.unused_checkpoints, value: this.data.collection.unused_loras + this.data.collection.unused_checkpoints,
label: 'Unused Models', label: translate('statistics.metrics.unusedModels'),
format: 'number' format: 'number'
}, },
{ {
icon: 'fas fa-chart-line', icon: 'fas fa-chart-line',
value: this.calculateAverageUsage(), value: this.calculateAverageUsage(),
label: 'Avg. Uses/Model', label: translate('statistics.metrics.avgUsesPerModel'),
format: 'decimal' format: 'decimal'
} }
]; ];
@@ -829,7 +840,7 @@ export class StatisticsManager {
const chartCanvases = document.querySelectorAll('canvas'); const chartCanvases = document.querySelectorAll('canvas');
chartCanvases.forEach(canvas => { chartCanvases.forEach(canvas => {
const container = canvas.parentElement; const container = canvas.parentElement;
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-chart-bar"></i> Chart requires Chart.js library</div>'; container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-chart-bar"></i> ' + translate('statistics.placeholders.chartLibraryMissing') + '</div>';
}); });
} }

View File

@@ -657,6 +657,9 @@ export class VirtualScroller {
this.resizeObserver.disconnect(); this.resizeObserver.disconnect();
} }
// Remove any active grid loading overlay
this.hideGridLoading();
// Remove rendered elements // Remove rendered elements
this.clearRenderedItems(); this.clearRenderedItems();
@@ -1130,4 +1133,30 @@ export class VirtualScroller {
index: targetIndex index: targetIndex
}; };
} }
/**
* Show a grid-scoped loading indicator (replaces full-page overlay)
* Only covers the card grid area, leaving header/sidebar unaffected.
*/
showGridLoading() {
// Remove any stale overlay from a prior deferred hide (e.g. from final rAF)
this.hideGridLoading();
const overlay = document.createElement('div');
overlay.className = 'grid-loading-overlay';
const spinner = document.createElement('div');
spinner.className = 'loading-spinner';
overlay.appendChild(spinner);
this.gridElement.appendChild(overlay);
this.gridLoadingOverlay = overlay;
}
/**
* Hide the grid-scoped loading indicator.
*/
hideGridLoading() {
if (this.gridLoadingOverlay) {
this.gridLoadingOverlay.remove();
this.gridLoadingOverlay = null;
}
}
} }

View File

@@ -0,0 +1,296 @@
/**
* genParamsMapper.js
* Maps display/recipe generation parameter values (sampler, scheduler) to
* ComfyUI internal widget values, enabling "Send Gen Params to Workflow".
*
* Strategy (3 layers):
* 1. Direct lookup via SAMPLER_DISPLAY_TO_INTERNAL
* 2. Combined-name parsing (e.g. "Euler a Karras" → sampler + scheduler)
* 3. Graceful skip for model-specific / unrecognized values
*/
// ---------------------------------------------------------------------------
// Sampler display name → internal name (ComfyUI KSampler.SAMPLERS / SAMPLER_NAMES)
// ---------------------------------------------------------------------------
const SAMPLER_DISPLAY_TO_INTERNAL = {
// --- Euler family ---
'Euler': 'euler',
'euler': 'euler',
'Euler a': 'euler_ancestral',
'Euler A': 'euler_ancestral',
'Euler ancestral': 'euler_ancestral',
'Euler Ancestral': 'euler_ancestral',
'euler_ancestral': 'euler_ancestral',
// --- Heun ---
'Heun': 'heun',
'heun': 'heun',
'Heun++': 'heunpp2',
'heunpp2': 'heunpp2',
// --- DPM2 ---
'DPM2': 'dpm_2',
'DPM 2': 'dpm_2',
'dpm_2': 'dpm_2',
'DPM2 a': 'dpm_2_ancestral',
'DPM2 Ancestral': 'dpm_2_ancestral',
'dpm_2_ancestral': 'dpm_2_ancestral',
// --- LMS ---
'LMS': 'lms',
'lms': 'lms',
// --- DPM fast / adaptive ---
'DPM fast': 'dpm_fast',
'DPM Fast': 'dpm_fast',
'dpm_fast': 'dpm_fast',
'DPM adaptive': 'dpm_adaptive',
'DPM Adaptive': 'dpm_adaptive',
'dpm_adaptive': 'dpm_adaptive',
// --- DPM++ 2S ancestral ---
'DPM++ 2S a': 'dpmpp_2s_ancestral',
'DPM++ 2S A': 'dpmpp_2s_ancestral',
'DPM++ 2S Ancestral': 'dpmpp_2s_ancestral',
'dpmpp_2s_ancestral': 'dpmpp_2s_ancestral',
// --- DPM++ SDE ---
'DPM++ SDE': 'dpmpp_sde',
'dpmpp_sde': 'dpmpp_sde',
// --- DPM++ 2M ---
'DPM++ 2M': 'dpmpp_2m',
'dpmpp_2m': 'dpmpp_2m',
// --- DPM++ 2M SDE ---
'DPM++ 2M SDE': 'dpmpp_2m_sde',
'dpmpp_2m_sde': 'dpmpp_2m_sde',
// --- DPM++ 3M SDE ---
'DPM++ 3M SDE': 'dpmpp_3m_sde',
'dpmpp_3m_sde': 'dpmpp_3m_sde',
// --- Others ---
'DDIM': 'ddim',
'ddim': 'ddim',
'DDPM': 'ddpm',
'ddpm': 'ddpm',
'LCM': 'lcm',
'lcm': 'lcm',
'IPNDM': 'ipndm',
'ipndm': 'ipndm',
'DEIS': 'deis',
'deis': 'deis',
'UniPC': 'uni_pc',
'unipc': 'uni_pc',
'uni_pc': 'uni_pc',
// --- Restart / res_multistep ---
'Restart': 'res_multistep',
'res_multistep': 'res_multistep',
// --- ER SDE ---
'ER SDE': 'er_sde',
'E-R SDE': 'er_sde',
'er_sde': 'er_sde',
// --- SA Solver ---
'SA Solver': 'sa_solver',
'SA solver': 'sa_solver',
'sa_solver': 'sa_solver',
// --- Seeds ---
'Seeds 2': 'seeds_2',
'seeds_2': 'seeds_2',
'Seeds 3': 'seeds_3',
'seeds_3': 'seeds_3',
};
// ---------------------------------------------------------------------------
// Known scheduler suffixes (ComfyUI KSampler.SCHEDULERS)
// Sorted by length (descending) for longest-match-first parsing.
// ---------------------------------------------------------------------------
const SCHEDULER_SUFFIXES = [
'sgm_uniform',
'ddim_uniform',
'linear_quadratic',
'kl_optimal',
'exponential',
'karras',
'simple',
'normal',
'beta',
];
// ---------------------------------------------------------------------------
// Scheduler-only values (values that are schedulers, not samplers)
// ---------------------------------------------------------------------------
const SCHEDULER_ONLY_VALUES = new Set([
'simple', 'sgm_uniform', 'karras', 'exponential',
'ddim_uniform', 'beta', 'normal', 'linear_quadratic', 'kl_optimal',
]);
// ---------------------------------------------------------------------------
// Param key → widget name candidates (searched in order)
// ---------------------------------------------------------------------------
const PARAM_TO_WIDGET_CANDIDATES = {
seed: ['seed', 'noise_seed'],
steps: ['steps'],
cfg: ['cfg'],
sampler: ['sampler_name', 'sampler'],
scheduler: ['scheduler'],
};
// ---------------------------------------------------------------------------
// Parse a combined sampler+scheduler value (space-separated or underscore)
// e.g., "Euler a Karras", "DPM++ 2M beta", "er_sde_beta"
// Returns { sampler: internalName|null, scheduler: internalName|null } or null
// ---------------------------------------------------------------------------
function parseCombinedSamplerName(rawValue) {
if (!rawValue || typeof rawValue !== 'string') return null;
const trimmed = rawValue.trim();
if (!trimmed) return null;
// Try space-separated first: split on last space
const spaceIdx = trimmed.lastIndexOf(' ');
if (spaceIdx > 0) {
const candidateScheduler = trimmed.slice(spaceIdx + 1).trim().toLowerCase();
if (SCHEDULER_SUFFIXES.includes(candidateScheduler)) {
const samplerPart = trimmed.slice(0, spaceIdx).trim();
const internalSampler = SAMPLER_DISPLAY_TO_INTERNAL[samplerPart];
if (internalSampler) {
return { sampler: internalSampler, scheduler: candidateScheduler };
}
// samplerPart might be a combined name itself (e.g., "DPM++ 2M SDE")
// Try recursing (one level max) — already handled since we split at last space
}
}
// Try underscore-separated: e.g., "er_sde_beta"
const underIdx = trimmed.lastIndexOf('_');
if (underIdx > 0) {
const candidateScheduler = trimmed.slice(underIdx + 1).trim().toLowerCase();
if (SCHEDULER_SUFFIXES.includes(candidateScheduler)) {
const samplerPart = trimmed.slice(0, underIdx).trim();
const internalSampler = SAMPLER_DISPLAY_TO_INTERNAL[samplerPart] || SAMPLER_DISPLAY_TO_INTERNAL[samplerPart.toLowerCase()];
if (internalSampler) {
return { sampler: internalSampler, scheduler: candidateScheduler };
}
}
}
return null;
}
// ---------------------------------------------------------------------------
// Main resolver: takes a raw sampler value from recipe/showcase metadata
// and returns { sampler: internalName|null, scheduler: internalName|null }
// ---------------------------------------------------------------------------
function resolveSamplerScheduler(rawValue) {
if (!rawValue || typeof rawValue !== 'string') {
return { sampler: null, scheduler: null };
}
const trimmed = rawValue.trim();
if (!trimmed) return { sampler: null, scheduler: null };
// 1. Try direct lookup first
const direct = SAMPLER_DISPLAY_TO_INTERNAL[trimmed];
if (direct) return { sampler: direct, scheduler: null };
// 2. Try lowercase direct lookup
const lowerDirect = SAMPLER_DISPLAY_TO_INTERNAL[trimmed.toLowerCase()];
if (lowerDirect) return { sampler: lowerDirect, scheduler: null };
// 3. Scheduler-only value? (check BEFORE the "already internal name" regex,
// because scheduler values like "karras", "simple" also match that pattern)
if (SCHEDULER_ONLY_VALUES.has(trimmed.toLowerCase())) {
return { sampler: null, scheduler: trimmed.toLowerCase() };
}
// 4. Already an internal name? (lowercase, no spaces)
if (/^[a-z][a-z0-9_]+$/.test(trimmed)) {
return { sampler: trimmed, scheduler: null };
}
// 5. Try combined name parsing (space-separated or underscore)
const combined = parseCombinedSamplerName(trimmed);
if (combined) return combined;
// 6. Custom format like "multistep/dpmpp_2m_simple" — try extracting the last segment
if (trimmed.includes('/')) {
const parts = trimmed.split('/');
const last = parts[parts.length - 1];
if (last) {
const subResult = resolveSamplerScheduler(last);
if (subResult.sampler || subResult.scheduler) return subResult;
}
}
// 7. Unrecognized — return null for both
return { sampler: null, scheduler: null };
}
// ---------------------------------------------------------------------------
// Find which gen params can be sent to a given node, matching by widget names
// Returns array of { widgetName, value } objects
// ---------------------------------------------------------------------------
function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
if (!nodeWidgetNames || !Array.isArray(nodeWidgetNames) || nodeWidgetNames.length === 0) {
return [];
}
const widgetSet = new Set(nodeWidgetNames.map(w => String(w).toLowerCase()));
const updates = [];
// Simple numeric/string params: seed, steps, cfg
const simpleParams = [
{ key: 'seed', value: resolvedParams.seed },
{ key: 'steps', value: resolvedParams.steps },
{ key: 'cfg', value: resolvedParams.cfg },
];
for (const { key, value } of simpleParams) {
if (value === undefined || value === null || value === '') continue;
const candidates = PARAM_TO_WIDGET_CANDIDATES[key] || [key];
for (const candidate of candidates) {
if (widgetSet.has(candidate.toLowerCase())) {
updates.push({ widgetName: candidate, value: String(value) });
break;
}
}
}
// Sampler
if (resolvedParams.sampler) {
const candidates = PARAM_TO_WIDGET_CANDIDATES.sampler;
for (const candidate of candidates) {
if (widgetSet.has(candidate.toLowerCase())) {
updates.push({ widgetName: candidate, value: resolvedParams.sampler });
break;
}
}
}
// Scheduler
if (resolvedParams.scheduler) {
const candidates = PARAM_TO_WIDGET_CANDIDATES.scheduler;
for (const candidate of candidates) {
if (widgetSet.has(candidate.toLowerCase())) {
updates.push({ widgetName: candidate, value: resolvedParams.scheduler });
break;
}
}
}
return updates;
}
export {
SAMPLER_DISPLAY_TO_INTERNAL,
SCHEDULER_SUFFIXES,
SCHEDULER_ONLY_VALUES,
PARAM_TO_WIDGET_CANDIDATES,
parseCombinedSamplerName,
resolveSamplerScheduler,
findMatchingWidgets,
};

View File

@@ -6,6 +6,7 @@ import { eventManager } from './EventManager.js';
import { bannerService } from '../managers/BannerService.js'; import { bannerService } from '../managers/BannerService.js';
import { modalManager } from '../managers/ModalManager.js'; import { modalManager } from '../managers/ModalManager.js';
import { buildCivitaiUrl, normalizeCivitaiPageHost } from './civitaiUtils.js'; import { buildCivitaiUrl, normalizeCivitaiPageHost } from './civitaiUtils.js';
import { resolveSamplerScheduler, findMatchingWidgets } from './genParamsMapper.js';
const CIVITAI_HOST_INFO_BANNER_ID = 'civitai-host-preference'; const CIVITAI_HOST_INFO_BANNER_ID = 'civitai-host-preference';
const CIVITAI_HOST_INFO_BANNER_SEEN_KEY = 'civitai_host_info_banner_seen'; const CIVITAI_HOST_INFO_BANNER_SEEN_KEY = 'civitai_host_info_banner_seen';
@@ -518,6 +519,22 @@ export function copyLoraSyntax(card) {
} }
} }
/**
* Strip <lora:...> tags from prompt text and clean up residual punctuation/whitespace.
* Handles both unescaped (<lora:...>) and HTML-escaped (&lt;lora:...&gt;) variants.
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
*/
export function stripLoraTags(text) {
return text
.replace(/<lora:[^>]*>/gi, '')
.replace(/&lt;lora:[^&]*&gt;/gi, '')
.replace(/,(\s*,)+/g, ',')
.replace(/^,\s*/, '')
.replace(/,\s*$/, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
async function fetchWorkflowRegistry() { async function fetchWorkflowRegistry() {
try { try {
const response = await fetch('/api/lm/get-registry'); const response = await fetch('/api/lm/get-registry');
@@ -840,11 +857,12 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
successMessage = 'Updated workflow node', successMessage = 'Updated workflow node',
failureMessage = 'Failed to update workflow node', failureMessage = 'Failed to update workflow node',
missingTargetMessage = 'No target node selected', missingTargetMessage = 'No target node selected',
silent = false,
} = messages; } = messages;
const targetIds = Array.isArray(nodeIds) ? nodeIds : []; const targetIds = Array.isArray(nodeIds) ? nodeIds : [];
if (targetIds.length === 0) { if (targetIds.length === 0) {
showToast(missingTargetMessage, {}, 'warning'); if (!silent) showToast(missingTargetMessage, {}, 'warning');
return false; return false;
} }
@@ -853,7 +871,7 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
.filter((reference) => reference && reference.node_id !== undefined); .filter((reference) => reference && reference.node_id !== undefined);
if (references.length === 0) { if (references.length === 0) {
showToast(missingTargetMessage, {}, 'warning'); if (!silent) showToast(missingTargetMessage, {}, 'warning');
return false; return false;
} }
@@ -872,16 +890,16 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
showToast(successMessage, {}, 'success'); if (!silent) showToast(successMessage, {}, 'success');
return true; return true;
} }
const errorMessage = result?.error || failureMessage; const errorMessage = result?.error || failureMessage;
showToast(errorMessage, {}, 'error'); if (!silent) showToast(errorMessage, {}, 'error');
return false; return false;
} catch (error) { } catch (error) {
console.error('Failed to send widget value to workflow:', error); console.error('Failed to send widget value to workflow:', error);
showToast(failureMessage, {}, 'error'); if (!silent) showToast(failureMessage, {}, 'error');
return false; return false;
} }
} }
@@ -915,7 +933,7 @@ async function sendTextToNodes(nodeIds, nodesMap, text, mode, messages = {}) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
widget_name: 'text', action: 'inject_text',
value: text, value: text,
mode: mode || 'append', mode: mode || 'append',
node_ids: references, node_ids: references,
@@ -948,7 +966,10 @@ export async function sendEmbeddingToWorkflow(embeddingCode) {
if (!isNodeEnabled(node)) { if (!isNodeEnabled(node)) {
return false; return false;
} }
return node.capabilities?.has_text_widget === true; return (
node.capabilities?.has_text_widget === true ||
node.marker_role === "send_prompt_target"
);
}); });
const nodeKeys = Object.keys(textNodes); const nodeKeys = Object.keys(textNodes);
@@ -980,6 +1001,184 @@ export async function sendEmbeddingToWorkflow(embeddingCode) {
return true; return true;
} }
/**
* Send prompt text to workflow text-capable nodes (replaces existing content).
* Uses the same target node discovery as sendEmbeddingToWorkflow.
* @param {string} promptText - The prompt/negative prompt text to send
* @param {Object} [options] - Optional messages overrides
* @param {string} [options.actionTypeText] - Label for the action type (default "Prompt")
* @param {string} [options.successMessage] - Success toast message
* @param {string} [options.failureMessage] - Failure toast message
* @param {string} [options.missingNodesMessage] - No nodes warning message
* @param {string} [options.missingTargetMessage] - No target selected warning message
* @returns {Promise<boolean>} Whether the send succeeded
*/
export async function sendPromptToWorkflow(promptText, options = {}) {
const registry = await fetchWorkflowRegistry();
if (!registry) {
return false;
}
const textNodes = filterRegistryNodes(registry.nodes, (node) => {
if (!isNodeEnabled(node)) {
return false;
}
return (
node.capabilities?.has_text_widget === true ||
node.marker_role === "send_prompt_target"
);
});
const nodeKeys = Object.keys(textNodes);
if (nodeKeys.length === 0) {
showToast(options.missingNodesMessage || 'uiHelpers.workflow.noMatchingNodes', {}, 'warning');
return false;
}
const messages = {
successMessage: options.successMessage || translate('uiHelpers.workflow.promptSent', {}, 'Prompt sent to workflow'),
failureMessage: options.failureMessage || translate('uiHelpers.workflow.promptFailed', {}, 'Failed to send prompt'),
missingTargetMessage: options.missingTargetMessage || translate('uiHelpers.workflow.noTargetNodeSelected', {}, 'No target node selected'),
};
const handleSend = (selectedNodeIds) =>
sendTextToNodes(selectedNodeIds, textNodes, promptText, 'replace', messages);
if (nodeKeys.length === 1) {
return await handleSend([nodeKeys[0]]);
}
const actionType = options.actionTypeText || translate('uiHelpers.nodeSelector.prompt', {}, 'Prompt');
showNodeSelector(textNodes, {
actionType,
actionMode: translate('uiHelpers.nodeSelector.replace', {}, 'Replace'),
onSend: handleSend,
});
return true;
}
/**
* Send generation parameters (seed, steps, cfg, sampler, scheduler) to
* workflow nodes that have been marked with "Send Gen Params Target".
*
* @param {Object} genParams - Raw gen_params from recipe or showcase metadata
* @returns {Promise<boolean>} Whether the send succeeded
*/
export async function sendGenParamsToWorkflow(genParams) {
if (!genParams || typeof genParams !== 'object') {
showToast('No generation parameters to send', {}, 'warning');
return false;
}
// 1. Extract relevant params (skip prompt, negative_prompt, clip_skip, denoising_strength)
const raw = {
seed: genParams.seed,
steps: genParams.steps,
cfg: genParams.cfg_scale,
};
// 2. Resolve sampler/scheduler
const resolved = resolveSamplerScheduler(genParams.sampler);
if (resolved) {
if (resolved.sampler) raw.sampler = resolved.sampler;
if (resolved.scheduler) raw.scheduler = resolved.scheduler;
}
// Check if we have anything to send
const hasAny = Object.values(raw).some(v => v !== undefined && v !== null && v !== '');
if (!hasAny) {
showToast('No sendable parameters found', {}, 'warning');
return false;
}
// 3. Fetch workflow registry
const registry = await fetchWorkflowRegistry();
if (!registry) {
return false;
}
// 4. Filter nodes by marker_role === "send_gen_params"
const targetNodes = filterRegistryNodes(registry.nodes, (node) => {
return node.marker_role === 'send_gen_params' && isNodeEnabled(node);
});
const nodeKeys = Object.keys(targetNodes);
if (nodeKeys.length === 0) {
showToast(
'No node marked as Send Gen Params Target.\nRight-click a node in ComfyUI → Mark as → Send Gen Params Target',
{},
'warning'
);
return false;
}
// 5. For each candidate node, find matching widgets
// Also collect widget_names from registry for matching
const sendToNode = async (nodeIds) => {
const targetIds = Array.isArray(nodeIds) ? nodeIds : [nodeIds];
let allSuccess = true;
let totalSent = 0;
let totalFailed = 0;
for (const nodeKey of targetIds) {
const node = targetNodes[nodeKey];
if (!node) continue;
const widgetNames = node.widget_names || [];
const updates = findMatchingWidgets(widgetNames, raw);
if (updates.length === 0) {
showToast(`Node "${node.title || node.type}" has no matching widgets for these parameters`, {}, 'warning');
allSuccess = false;
continue;
}
// Send each widget value sequentially
for (const update of updates) {
const success = await sendWidgetValueToNodes(
[nodeKey],
targetNodes,
update.widgetName,
update.value,
{
silent: true,
}
);
if (success) {
totalSent++;
} else {
totalFailed++;
allSuccess = false;
}
}
}
// Show single summary toast
if (totalSent > 0 && totalFailed === 0) {
showToast(`Sent ${totalSent} parameter${totalSent > 1 ? 's' : ''} to workflow`, {}, 'success');
} else if (totalFailed > 0 && totalSent > 0) {
showToast(`Partially updated (${totalSent} ok, ${totalFailed} failed)`, {}, 'warning');
} else if (totalFailed > 0) {
showToast('Failed to update parameters', {}, 'error');
}
return allSuccess;
};
// 6. If multiple nodes, show node selector; otherwise send directly
if (nodeKeys.length === 1) {
return await sendToNode([nodeKeys[0]]);
}
showNodeSelector(targetNodes, {
actionType: 'Gen Params',
actionMode: 'Update',
onSend: sendToNode,
enableSendAll: true,
});
return true;
}
// Global variable to track active node selector state // Global variable to track active node selector state
let nodeSelectorState = { let nodeSelectorState = {
isActive: false, isActive: false,

View File

@@ -158,6 +158,11 @@
<div class="context-menu-item" data-action="manage-excluded-models"> <div class="context-menu-item" data-action="manage-excluded-models">
<i class="fas fa-eye-slash"></i> <span>{{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }}</span> <i class="fas fa-eye-slash"></i> <span>{{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }}</span>
</div> </div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="toggle-group-by-model">
<i class="fas fa-layer-group"></i> <span>{{ t('globalContextMenu.groupByModel.label') }}</span>
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div>
<div class="context-menu-item" data-action="repair-recipes"> <div class="context-menu-item" data-action="repair-recipes">
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span> <i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
</div> </div>

View File

@@ -1,4 +1,5 @@
<div class="controls"> <div class="controls">
{% if page_id != 'recipes' %}
<div id="excludedViewBanner" class="excluded-view-banner hidden"> <div id="excludedViewBanner" class="excluded-view-banner hidden">
<div class="excluded-view-banner__content"> <div class="excluded-view-banner__content">
<div class="excluded-view-banner__title"> <div class="excluded-view-banner__title">
@@ -11,42 +12,58 @@
</button> </button>
</div> </div>
</div> </div>
{% endif %}
<div class="actions"> <div class="actions">
<div class="action-buttons"> <div class="action-buttons">
<div title="{{ t('loras.controls.sort.title') }}" class="control-group"> <div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group">
<select id="sortSelect"> <select id="sortSelect">
<optgroup label="{{ t('loras.controls.sort.name') }}"> <optgroup label="{{ t('loras.controls.sort.name') }}">
<option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option> <option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option>
<option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option> <option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option>
</optgroup> </optgroup>
<optgroup label="{{ t('loras.controls.sort.date') }}"> <optgroup label="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.date') }}{% else %}{{ t('loras.controls.sort.date') }}{% endif %}">
<option value="date:desc">{{ t('loras.controls.sort.dateDesc') }}</option> <option value="date:desc">{{ t('loras.controls.sort.dateDesc') }}</option>
<option value="date:asc">{{ t('loras.controls.sort.dateAsc') }}</option> <option value="date:asc">{{ t('loras.controls.sort.dateAsc') }}</option>
</optgroup> </optgroup>
{% if page_id != 'recipes' %}
<optgroup label="{{ t('loras.controls.sort.size') }}"> <optgroup label="{{ t('loras.controls.sort.size') }}">
<option value="size:desc">{{ t('loras.controls.sort.sizeDesc') }}</option> <option value="size:desc">{{ t('loras.controls.sort.sizeDesc') }}</option>
<option value="size:asc">{{ t('loras.controls.sort.sizeAsc') }}</option> <option value="size:asc">{{ t('loras.controls.sort.sizeAsc') }}</option>
</optgroup> </optgroup>
{% if page_id != 'embeddings' %} {% endif %}
{% if page_id != 'embeddings' and page_id != 'recipes' %}
<optgroup label="{{ t('loras.controls.sort.usage', default='Usage') }}"> <optgroup label="{{ t('loras.controls.sort.usage', default='Usage') }}">
<option value="usage:desc">{{ t('loras.controls.sort.usageDesc', default='Times used (high to low)') }}</option> <option value="usage:desc">{{ t('loras.controls.sort.usageDesc', default='Times used (high to low)') }}</option>
<option value="usage:asc">{{ t('loras.controls.sort.usageAsc', default='Times used (low to high)') }}</option> <option value="usage:asc">{{ t('loras.controls.sort.usageAsc', default='Times used (low to high)') }}</option>
</optgroup> </optgroup>
{% endif %} {% endif %}
{% if page_id != 'recipes' %}
<optgroup class="sort-option-versions-count" label="{{ t('loras.controls.sort.versionsCount', default='Local Versions') }}">
<option value="versions_count:desc">{{ t('loras.controls.sort.versionsCountDesc', default='Most versions first') }}</option>
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
</optgroup>
{% endif %}
{% if page_id == 'recipes' %}
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
</optgroup>
{% endif %}
</select> </select>
</div> </div>
<div title="{{ t('loras.controls.refresh.title') }}" class="control-group dropdown-group"> <div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{ t('common.actions.refresh') }}</span></button> <button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{ t('common.actions.refresh') }}</span></button>
<button class="dropdown-toggle" aria-label="Show refresh options"> <button class="dropdown-toggle" aria-label="Show refresh options">
<i class="fas fa-caret-down"></i> <i class="fas fa-caret-down"></i>
</button> </button>
<div class="dropdown-menu"> <div class="dropdown-menu">
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('loras.controls.refresh.fullTooltip') }}"> <div class="dropdown-item" data-action="full-rebuild" title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}{% else %}{{ t('loras.controls.refresh.fullTooltip') }}{% endif %}">
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full') }}</span> <i class="fas fa-tools"></i> <span>{% if page_id == 'recipes' %}{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}{% else %}{{ t('loras.controls.refresh.full') }}{% endif %}</span>
</div> </div>
</div> </div>
</div> </div>
{% if page_id != 'recipes' %}
<div class="control-group"> <div class="control-group">
<button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span>{{ t('loras.controls.fetch.action') }}</span></button> <button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span>{{ t('loras.controls.fetch.action') }}</span></button>
</div> </div>
@@ -55,6 +72,15 @@
<i class="fas fa-cloud-download-alt"></i> <span>{{ t('loras.controls.download.action') }}</span> <i class="fas fa-cloud-download-alt"></i> <span>{{ t('loras.controls.download.action') }}</span>
</button> </button>
</div> </div>
{% endif %}
{% if page_id == 'recipes' %}
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{ t('recipes.controls.import.action') }}</button>
</div>
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{ t('recipes.batchImport.action') }}</button>
</div>
{% endif %}
<div class="control-group"> <div class="control-group">
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}"> <button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <div class="shortcut-key">B</div></span> <i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <div class="shortcut-key">B</div></span>
@@ -71,6 +97,7 @@
<i class="fas fa-star"></i> <span>{{ t('loras.controls.favorites.action') }}</span> <i class="fas fa-star"></i> <span>{{ t('loras.controls.favorites.action') }}</span>
</button> </button>
</div> </div>
{% if page_id != 'recipes' %}
<div class="control-group dropdown-group update-filter-group"> <div class="control-group dropdown-group update-filter-group">
<button id="updateFilterBtn" data-action="toggle-updates" class="dropdown-main update-filter" title="{{ t('loras.controls.updates.title') }}"> <button id="updateFilterBtn" data-action="toggle-updates" class="dropdown-main update-filter" title="{{ t('loras.controls.updates.title') }}">
<i class="fas fa-exclamation-circle"></i> <span>{{ t('loras.controls.updates.action') }}</span> <i class="fas fa-exclamation-circle"></i> <span>{{ t('loras.controls.updates.action') }}</span>
@@ -84,6 +111,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<div id="customFilterIndicator" class="control-group hidden"> <div id="customFilterIndicator" class="control-group hidden">
<div class="filter-active"> <div class="filter-active">
<i class="fas fa-filter"></i> <span class="customFilterText" title=""></span> <i class="fas fa-filter"></i> <span class="customFilterText" title=""></span>

View File

@@ -45,14 +45,14 @@
<!-- Center section: Search --> <!-- Center section: Search -->
{% set search_disabled = current_page == 'statistics' %} {% set search_disabled = current_page == 'statistics' %}
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~ {% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholder' %}
current_page %}
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %} {% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
<div class="{{ header_search_class }}" id="headerSearch"> <div class="{{ header_search_class }}" id="headerSearch">
<div class="search-container"> <div class="search-container">
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %} <input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
disabled{% endif %} /> disabled{% endif %} />
<i class="fas fa-search search-icon"></i> <i class="fas fa-search search-icon"></i>
<span class="search-shortcut-cue" id="searchShortcutCue"><kbd>Ctrl</kbd><kbd>F</kbd></span>
<button class="search-options-toggle" id="searchOptionsToggle" title="{{ t('header.search.options') }}" {% if <button class="search-options-toggle" id="searchOptionsToggle" title="{{ t('header.search.options') }}" {% if
search_disabled %} disabled aria-disabled="true" {% endif %}> search_disabled %} disabled aria-disabled="true" {% endif %}>
<i class="fas fa-sliders-h"></i> <i class="fas fa-sliders-h"></i>

View File

@@ -536,6 +536,25 @@
</div> </div>
</div> </div>
<!-- Group by model toggle -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="groupByModel">
{{ t('settings.layoutSettings.groupByModel') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.groupByModelHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="groupByModel"
onchange="settingsManager.saveToggleSetting('groupByModel', 'group_by_model')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
@@ -1081,23 +1100,23 @@
</div> </div>
</div> </div>
<!-- Update Flags --> <!-- Version Scope -->
<div class="settings-subsection"> <div class="settings-subsection">
<div class="settings-subsection-header"> <div class="settings-subsection-header">
<h4>{{ t('settings.sections.updateFlags') }}</h4> <h4>{{ t('settings.sections.versionScope') }}</h4>
</div> </div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<label for="updateFlagStrategy"> <label for="versionGrouping">
{{ t('settings.updateFlagStrategy.label') }} {{ t('settings.versionGrouping.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.updateFlagStrategy.help') }}"></i> <i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.versionGrouping.help') }}"></i>
</label> </label>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="updateFlagStrategy" onchange="settingsManager.saveSelectSetting('updateFlagStrategy', 'update_flag_strategy')"> <select id="versionGrouping" onchange="settingsManager.saveSelectSetting('versionGrouping', 'version_grouping')">
<option value="same_base">{{ t('settings.updateFlagStrategy.options.sameBase') }}</option> <option value="same_base">{{ t('settings.versionGrouping.options.sameBase') }}</option>
<option value="any">{{ t('settings.updateFlagStrategy.options.any') }}</option> <option value="any">{{ t('settings.versionGrouping.options.any') }}</option>
</select> </select>
</div> </div>
</div> </div>

View File

@@ -6,13 +6,8 @@
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<!-- Header Actions: populated dynamically in RecipeModal.js --> <!-- Header Actions: populated dynamically in RecipeModal.js -->
<div class="recipe-header-actions" id="recipeHeaderActions"></div> <div class="recipe-header-actions" id="recipeHeaderActions"></div>
<!-- Recipe Tags Container --> <!-- Recipe Tags Container (rendered by renderCompactTags) -->
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
@@ -41,6 +36,9 @@
<div class="param-header"> <div class="param-header">
<label>Prompt</label> <label>Prompt</label>
<div class="param-actions"> <div class="param-actions">
<button class="copy-btn" id="sendPromptBtn" title="Send Prompt to Workflow">
<i class="fas fa-paper-plane"></i>
</button>
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"> <button class="copy-btn" id="copyPromptBtn" title="Copy Prompt">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
</button> </button>
@@ -67,6 +65,9 @@
<div class="param-header"> <div class="param-header">
<label>Negative Prompt</label> <label>Negative Prompt</label>
<div class="param-actions"> <div class="param-actions">
<button class="copy-btn" id="sendNegativePromptBtn" title="Send Negative Prompt to Workflow">
<i class="fas fa-paper-plane"></i>
</button>
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"> <button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
</button> </button>
@@ -89,7 +90,17 @@
</div> </div>
<!-- Other Parameters --> <!-- Other Parameters -->
<div class="other-params" id="recipeOtherParams"></div> <div class="param-group info-item">
<div class="param-header">
<label>Params</label>
<div class="param-actions">
<button class="copy-btn" id="sendParamsBtn" title="Send Params to Workflow">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -62,91 +62,9 @@
{% block content %} {% block content %}
<!-- Recipe controls --> <!-- Recipe controls -->
<div class="controls"> {% include 'components/controls.html' %}
<div class="actions"> <!-- Breadcrumb Navigation -->
<div class="action-buttons"> {% include 'components/breadcrumb.html' %}
<div class="control-group">
<select id="sortSelect" title="{{ t('recipes.controls.sort.title') }}">
<optgroup label="{{ t('recipes.controls.sort.name') }}">
<option value="name:asc">{{ t('recipes.controls.sort.nameAsc') }}</option>
<option value="name:desc">{{ t('recipes.controls.sort.nameDesc') }}</option>
</optgroup>
<optgroup label="{{ t('recipes.controls.sort.date') }}">
<option value="date:desc">{{ t('recipes.controls.sort.dateDesc') }}</option>
<option value="date:asc">{{ t('recipes.controls.sort.dateAsc') }}</option>
</optgroup>
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
</optgroup>
</select>
</div>
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group dropdown-group">
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{
t('common.actions.refresh') }}</span></button>
<button class="dropdown-toggle" aria-label="Show refresh options">
<i class="fas fa-caret-down"></i>
</button>
<div class="dropdown-menu">
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
</div>
</div>
</div>
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
t('recipes.controls.import.action') }}</button>
</div>
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{
t('recipes.batchImport.action') }}</button>
</div>
<div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>
<div class="shortcut-key">B</div>
</span>
</button>
</div>
<!-- Add duplicate detection button -->
<div title="{{ t('loras.controls.duplicates.title') }}" class="control-group">
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{
t('loras.controls.duplicates.action') }}</button>
</div>
<div class="control-group">
<button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter"
title="{{ t('recipes.controls.favorites.title') }}">
<i class="fas fa-star"></i> <span>{{ t('recipes.controls.favorites.action') }}</span>
</button>
</div>
<!-- Custom filter indicator button (hidden by default) -->
<div id="customFilterIndicator" class="control-group hidden">
<div class="filter-active">
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora')
}}</span>
<i class="fas fa-times-circle clear-filter"></i>
</div>
</div>
</div>
<div class="controls-right">
<div class="control-group doctor-control-group">
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
<i class="fas fa-stethoscope"></i>
<span>{{ t('doctor.title', default='Doctor') }}</span>
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
</button>
</div>
</div>
</div>
<!-- Breadcrumb Navigation -->
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
<!-- Breadcrumbs will be populated by JavaScript -->
</nav>
</div>
</div>
<!-- Duplicates banner (hidden by default) --> <!-- Duplicates banner (hidden by default) -->
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;"> <div id="duplicatesBanner" class="duplicates-banner" style="display: none;">

View File

@@ -246,12 +246,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">
@@ -375,12 +370,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">
@@ -474,12 +464,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">
@@ -588,12 +573,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">
@@ -682,12 +662,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">
@@ -790,12 +765,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">
@@ -873,12 +843,10 @@ describe('Interaction-level regression coverage', () => {
}); });
recipeModal.markFieldDirty('title'); recipeModal.markFieldDirty('title');
recipeModal.markFieldDirty('tags');
recipeModal.markFieldDirty('prompt'); recipeModal.markFieldDirty('prompt');
recipeModal.markFieldDirty('negative_prompt'); recipeModal.markFieldDirty('negative_prompt');
document.querySelector('#recipeTitleEditor .title-input').value = 'Local Title'; document.querySelector('#recipeTitleEditor .title-input').value = 'Local Title';
document.querySelector('#recipeTagsEditor .tags-input').value = 'local-tag-1, local-tag-2';
document.getElementById('recipePromptInput').value = 'local prompt'; document.getElementById('recipePromptInput').value = 'local prompt';
document.getElementById('recipeNegativePromptInput').value = 'local negative'; document.getElementById('recipeNegativePromptInput').value = 'local negative';
@@ -899,7 +867,6 @@ describe('Interaction-level regression coverage', () => {
await flushAsyncTasks(); await flushAsyncTasks();
expect(document.querySelector('#recipeTitleEditor .title-input').value).toBe('Local Title'); expect(document.querySelector('#recipeTitleEditor .title-input').value).toBe('Local Title');
expect(document.querySelector('#recipeTagsEditor .tags-input').value).toBe('local-tag-1, local-tag-2');
expect(document.getElementById('recipePromptInput').value).toBe('local prompt'); expect(document.getElementById('recipePromptInput').value).toBe('local prompt');
expect(document.getElementById('recipeNegativePromptInput').value).toBe('local negative'); expect(document.getElementById('recipeNegativePromptInput').value).toBe('local negative');
expect(recipeModal.currentRecipe.title).toBe('Hydrated Title'); expect(recipeModal.currentRecipe.title).toBe('Hydrated Title');
@@ -918,12 +885,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">
@@ -1057,12 +1019,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">
@@ -1160,9 +1117,9 @@ describe('Interaction-level regression coverage', () => {
expect(document.getElementById('recipePrompt').textContent).toBe('No prompt information available'); expect(document.getElementById('recipePrompt').textContent).toBe('No prompt information available');
expect(document.getElementById('recipeNegativePrompt').textContent).toBe('No negative prompt information available'); expect(document.getElementById('recipeNegativePrompt').textContent).toBe('No negative prompt information available');
const otherParamsText = document.getElementById('recipeOtherParams').textContent; const otherParamsText = document.getElementById('recipeOtherParams').textContent;
expect(otherParamsText).toContain('sampler:'); expect(otherParamsText).toContain('Sampler:');
expect(otherParamsText).toContain('dpmpp_2m'); expect(otherParamsText).toContain('dpmpp_2m');
expect(otherParamsText).not.toContain('cfg_scale'); expect(otherParamsText).not.toContain('CFG');
}); });
it('filters dirty generation params from recipe modal display', async () => { it('filters dirty generation params from recipe modal display', async () => {
@@ -1170,8 +1127,7 @@ describe('Interaction-level regression coverage', () => {
<div id="recipeModal" class="modal"> <div id="recipeModal" class="modal">
<div id="recipeModalTitle"></div> <div id="recipeModalTitle"></div>
<div id="recipePreviewContainer"></div> <div id="recipePreviewContainer"></div>
<div id="recipeTagsCompact"></div> <div id="recipeTagsContainer"></div>
<div id="recipeTagsTooltip"><div id="recipeTagsTooltipContent"></div></div>
<div id="recipePrompt"></div> <div id="recipePrompt"></div>
<textarea id="recipePromptInput"></textarea> <textarea id="recipePromptInput"></textarea>
<div id="recipeNegativePrompt"></div> <div id="recipeNegativePrompt"></div>
@@ -1212,8 +1168,8 @@ describe('Interaction-level regression coverage', () => {
const otherParamsText = document.getElementById('recipeOtherParams').textContent; const otherParamsText = document.getElementById('recipeOtherParams').textContent;
expect(document.getElementById('recipePrompt').textContent).toContain('visible prompt'); expect(document.getElementById('recipePrompt').textContent).toContain('visible prompt');
expect(document.getElementById('recipeNegativePrompt').textContent).toContain('visible negative'); expect(document.getElementById('recipeNegativePrompt').textContent).toContain('visible negative');
expect(otherParamsText).toContain('sampler:'); expect(otherParamsText).toContain('Sampler:');
expect(otherParamsText).toContain('cfg_scale:'); expect(otherParamsText).toContain('CFG:');
expect(otherParamsText).not.toContain('Version'); expect(otherParamsText).not.toContain('Version');
expect(otherParamsText).not.toContain('raw_metadata'); expect(otherParamsText).not.toContain('raw_metadata');
expect(otherParamsText).not.toContain('RNG'); expect(otherParamsText).not.toContain('RNG');
@@ -1224,8 +1180,7 @@ describe('Interaction-level regression coverage', () => {
<div id="recipeModal" class="modal"> <div id="recipeModal" class="modal">
<div id="recipeModalTitle"></div> <div id="recipeModalTitle"></div>
<div id="recipePreviewContainer"></div> <div id="recipePreviewContainer"></div>
<div id="recipeTagsCompact"></div> <div id="recipeTagsContainer"></div>
<div id="recipeTagsTooltip"><div id="recipeTagsTooltipContent"></div></div>
<div id="recipePrompt"></div> <div id="recipePrompt"></div>
<textarea id="recipePromptInput"></textarea> <textarea id="recipePromptInput"></textarea>
<div id="recipeNegativePrompt"></div> <div id="recipeNegativePrompt"></div>
@@ -1267,7 +1222,7 @@ describe('Interaction-level regression coverage', () => {
expect(document.getElementById('recipePrompt').textContent).not.toContain('stale prompt'); expect(document.getElementById('recipePrompt').textContent).not.toContain('stale prompt');
expect(document.getElementById('recipeNegativePrompt').textContent).toContain('fresh negative'); expect(document.getElementById('recipeNegativePrompt').textContent).toContain('fresh negative');
expect(document.getElementById('recipeNegativePrompt').textContent).not.toContain('stale negative'); expect(document.getElementById('recipeNegativePrompt').textContent).not.toContain('stale negative');
expect(otherParamsText).toContain('cfg_scale:'); expect(otherParamsText).toContain('CFG:');
expect(otherParamsText).toContain('7'); expect(otherParamsText).toContain('7');
expect(otherParamsText).not.toContain('3'); expect(otherParamsText).not.toContain('3');
}); });
@@ -1300,12 +1255,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">
@@ -1418,12 +1368,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">
@@ -1541,12 +1486,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">
@@ -1654,12 +1594,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">
@@ -1776,12 +1711,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">
@@ -1878,12 +1808,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">
@@ -2007,12 +1932,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container"> <div id="recipeTagsContainer"></div>
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-top-section">

View File

@@ -33,7 +33,7 @@ const stateMock = {
global: { global: {
settings: { settings: {
autoplay_on_hover: false, autoplay_on_hover: false,
update_flag_strategy: 'any', version_grouping: 'any',
}, },
}, },
}; };
@@ -67,7 +67,7 @@ describe('ModelVersionsTab media rendering', () => {
</div> </div>
`; `;
stateMock.global.settings.autoplay_on_hover = false; stateMock.global.settings.autoplay_on_hover = false;
stateMock.global.settings.update_flag_strategy = 'any'; stateMock.global.settings.version_grouping = 'any';
({ getModelApiClient } = await import(API_FACTORY_MODULE)); ({ getModelApiClient } = await import(API_FACTORY_MODULE));
fetchModelUpdateVersions = vi.fn(); fetchModelUpdateVersions = vi.fn();
getModelApiClient.mockReturnValue({ getModelApiClient.mockReturnValue({
@@ -157,7 +157,7 @@ describe('ModelVersionsTab media rendering', () => {
}); });
it('shows a stable label with a short state indicator', async () => { it('shows a stable label with a short state indicator', async () => {
stateMock.global.settings.update_flag_strategy = 'any'; stateMock.global.settings.version_grouping = 'any';
fetchModelUpdateVersions.mockResolvedValue({ fetchModelUpdateVersions.mockResolvedValue({
success: true, success: true,
record: { record: {
@@ -192,7 +192,7 @@ describe('ModelVersionsTab media rendering', () => {
}); });
it('filters versions to the current base model when strategy is same_base', async () => { it('filters versions to the current base model when strategy is same_base', async () => {
stateMock.global.settings.update_flag_strategy = 'same_base'; stateMock.global.settings.version_grouping = 'same_base';
fetchModelUpdateVersions.mockResolvedValue({ fetchModelUpdateVersions.mockResolvedValue({
success: true, success: true,
record: { record: {
@@ -235,7 +235,7 @@ describe('ModelVersionsTab media rendering', () => {
}); });
it('toggle button can switch to display all versions', async () => { it('toggle button can switch to display all versions', async () => {
stateMock.global.settings.update_flag_strategy = 'same_base'; stateMock.global.settings.version_grouping = 'same_base';
fetchModelUpdateVersions.mockResolvedValue({ fetchModelUpdateVersions.mockResolvedValue({
success: true, success: true,
record: { record: {
@@ -286,7 +286,7 @@ describe('ModelVersionsTab media rendering', () => {
}); });
it('shows a newer version badge when viewing same-base results', async () => { it('shows a newer version badge when viewing same-base results', async () => {
stateMock.global.settings.update_flag_strategy = 'same_base'; stateMock.global.settings.version_grouping = 'same_base';
fetchModelUpdateVersions.mockResolvedValue({ fetchModelUpdateVersions.mockResolvedValue({
success: true, success: true,
record: { record: {

View File

@@ -96,6 +96,7 @@ function renderControlsDom(pageKey) {
<div class="search-container"> <div class="search-container">
<input id="searchInput" /> <input id="searchInput" />
<i class="fas fa-search search-icon"></i> <i class="fas fa-search search-icon"></i>
<span class="search-shortcut-cue" id="searchShortcutCue"><kbd>Ctrl</kbd><kbd>F</kbd></span>
<button id="searchOptionsToggle" class="search-options-toggle"></button> <button id="searchOptionsToggle" class="search-options-toggle"></button>
<button id="filterButton" class="search-filter-toggle"> <button id="filterButton" class="search-filter-toggle">
<span id="activeFiltersCount" class="filter-badge" style="display: none">0</span> <span id="activeFiltersCount" class="filter-badge" style="display: none">0</span>
@@ -215,6 +216,40 @@ describe('SearchManager filtering scenarios', () => {
expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledWith(true, false); expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledWith(true, false);
expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1); expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1);
}); });
it.each([
['loras'],
['checkpoints'],
])('toggles clear button and shortcut cue visibility for %s page', async (pageKey) => {
vi.useFakeTimers();
renderControlsDom(pageKey);
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState(pageKey);
const { SearchManager } = await import('../../../static/js/managers/SearchManager.js');
new SearchManager({ page: pageKey, searchDelay: 0 });
const input = document.getElementById('searchInput');
const cue = document.getElementById('searchShortcutCue');
const clearBtn = document.querySelector('.search-clear');
// Initially empty: cue visible, clear hidden
expect(cue.classList.contains('hidden')).toBe(false);
expect(clearBtn.classList.contains('visible')).toBe(false);
// Type something: cue hidden, clear visible
input.value = 'flux';
input.dispatchEvent(new Event('input', { bubbles: true }));
expect(cue.classList.contains('hidden')).toBe(true);
expect(clearBtn.classList.contains('visible')).toBe(true);
// Clear via click: cue visible, clear hidden
clearBtn.click();
expect(input.value).toBe('');
expect(cue.classList.contains('hidden')).toBe(false);
expect(clearBtn.classList.contains('visible')).toBe(false);
});
}); });
describe('FilterManager tag and base model filters', () => { describe('FilterManager tag and base model filters', () => {

View File

@@ -143,6 +143,19 @@ describe('RecipeManager', () => {
renderRecipesPage(); renderRecipesPage();
// Inject controls DOM that would normally come from components/controls.html
// (raw template rendering doesn't process Jinja2 {% include %} tags)
const customFilterIndicator = document.createElement('div');
customFilterIndicator.id = 'customFilterIndicator';
customFilterIndicator.className = 'control-group hidden';
customFilterIndicator.innerHTML = `
<div class="filter-active">
<i class="fas fa-filter"></i> <span class="customFilterText" title=""></span>
<i class="fas fa-times-circle clear-filter"></i>
</div>
`;
document.body.appendChild(customFilterIndicator);
({ RecipeManager } = await import('../../../static/js/recipes.js')); ({ RecipeManager } = await import('../../../static/js/recipes.js'));
}); });
@@ -288,7 +301,7 @@ describe('RecipeManager', () => {
}); });
const indicator = document.getElementById('customFilterIndicator'); const indicator = document.getElementById('customFilterIndicator');
const filterText = indicator.querySelector('#customFilterText'); const filterText = indicator.querySelector('.customFilterText');
expect(filterText.innerHTML).toContain('Recipes using checkpoint:'); expect(filterText.innerHTML).toContain('Recipes using checkpoint:');
expect(filterText.innerHTML).toContain('Flux Base'); expect(filterText.innerHTML).toContain('Flux Base');

View File

@@ -0,0 +1,246 @@
import { describe, it, expect } from 'vitest';
// genParamsMapper is pure logic with zero dependencies — safe to import directly
import {
SAMPLER_DISPLAY_TO_INTERNAL,
SCHEDULER_SUFFIXES,
SCHEDULER_ONLY_VALUES,
PARAM_TO_WIDGET_CANDIDATES,
parseCombinedSamplerName,
resolveSamplerScheduler,
findMatchingWidgets,
} from '../../../static/js/utils/genParamsMapper.js';
// ---------------------------------------------------------------------------
// Constants sanity
// ---------------------------------------------------------------------------
describe('constants', () => {
it('maps at least the common samplers', () => {
expect(SAMPLER_DISPLAY_TO_INTERNAL['Euler']).toBe('euler');
expect(SAMPLER_DISPLAY_TO_INTERNAL['Euler a']).toBe('euler_ancestral');
expect(SAMPLER_DISPLAY_TO_INTERNAL['DPM++ 2M']).toBe('dpmpp_2m');
expect(SAMPLER_DISPLAY_TO_INTERNAL['DPM++ 2M SDE']).toBe('dpmpp_2m_sde');
expect(SAMPLER_DISPLAY_TO_INTERNAL['LCM']).toBe('lcm');
expect(SAMPLER_DISPLAY_TO_INTERNAL['DDIM']).toBe('ddim');
});
it('lists all 9 scheduler suffixes', () => {
expect(SCHEDULER_SUFFIXES).toHaveLength(9);
expect(SCHEDULER_SUFFIXES).toContain('karras');
expect(SCHEDULER_SUFFIXES).toContain('simple');
expect(SCHEDULER_SUFFIXES).toContain('exponential');
});
it('marks scheduler-only values', () => {
expect(SCHEDULER_ONLY_VALUES.has('karras')).toBe(true);
expect(SCHEDULER_ONLY_VALUES.has('simple')).toBe(true);
expect(SCHEDULER_ONLY_VALUES.has('euler')).toBe(false);
});
it('has widget candidates for all param keys', () => {
expect(PARAM_TO_WIDGET_CANDIDATES.seed).toContain('seed');
expect(PARAM_TO_WIDGET_CANDIDATES.sampler).toContain('sampler_name');
expect(PARAM_TO_WIDGET_CANDIDATES.scheduler).toContain('scheduler');
});
});
// ---------------------------------------------------------------------------
// parseCombinedSamplerName
// ---------------------------------------------------------------------------
describe('parseCombinedSamplerName', () => {
it('parses space-separated sampler + scheduler', () => {
expect(parseCombinedSamplerName('Euler a Karras')).toEqual({
sampler: 'euler_ancestral',
scheduler: 'karras',
});
});
it('parses DPM++ 2M Karras', () => {
expect(parseCombinedSamplerName('DPM++ 2M Karras')).toEqual({
sampler: 'dpmpp_2m',
scheduler: 'karras',
});
});
it('parses DPM++ 2M beta', () => {
expect(parseCombinedSamplerName('DPM++ 2M beta')).toEqual({
sampler: 'dpmpp_2m',
scheduler: 'beta',
});
});
it('parses DPM++ SDE Karras', () => {
expect(parseCombinedSamplerName('DPM++ SDE Karras')).toEqual({
sampler: 'dpmpp_sde',
scheduler: 'karras',
});
});
it('parses underscore-separated er_sde_beta', () => {
expect(parseCombinedSamplerName('er_sde_beta')).toEqual({
sampler: 'er_sde',
scheduler: 'beta',
});
});
it('returns null for sampler-only values', () => {
expect(parseCombinedSamplerName('Euler a')).toBeNull();
expect(parseCombinedSamplerName('LCM')).toBeNull();
});
it('returns null for unrecognised suffix', () => {
expect(parseCombinedSamplerName('Euler something_unknown')).toBeNull();
});
it('returns null for null/empty', () => {
expect(parseCombinedSamplerName(null)).toBeNull();
expect(parseCombinedSamplerName('')).toBeNull();
});
});
// ---------------------------------------------------------------------------
// resolveSamplerScheduler — the main resolver used by the send feature
// ---------------------------------------------------------------------------
describe('resolveSamplerScheduler', () => {
// --- Category 1: simple display names ---
it('resolves Euler → euler', () => {
expect(resolveSamplerScheduler('Euler')).toEqual({ sampler: 'euler', scheduler: null });
});
it('resolves Euler a → euler_ancestral', () => {
expect(resolveSamplerScheduler('Euler a')).toEqual({ sampler: 'euler_ancestral', scheduler: null });
});
it('resolves DPM++ 2M → dpmpp_2m', () => {
expect(resolveSamplerScheduler('DPM++ 2M')).toEqual({ sampler: 'dpmpp_2m', scheduler: null });
});
it('resolves LCM → lcm', () => {
expect(resolveSamplerScheduler('LCM')).toEqual({ sampler: 'lcm', scheduler: null });
});
// --- Category 2: already-internal names ---
it('passes through lowercase internal names', () => {
expect(resolveSamplerScheduler('euler')).toEqual({ sampler: 'euler', scheduler: null });
expect(resolveSamplerScheduler('heunpp2')).toEqual({ sampler: 'heunpp2', scheduler: null });
expect(resolveSamplerScheduler('lcm')).toEqual({ sampler: 'lcm', scheduler: null });
expect(resolveSamplerScheduler('er_sde')).toEqual({ sampler: 'er_sde', scheduler: null });
});
// --- Category 3: combined names ---
it('resolves Euler a Karras → euler_ancestral + karras', () => {
expect(resolveSamplerScheduler('Euler a Karras')).toEqual({
sampler: 'euler_ancestral',
scheduler: 'karras',
});
});
it('resolves DPM++ 2M Karras → dpmpp_2m + karras', () => {
expect(resolveSamplerScheduler('DPM++ 2M Karras')).toEqual({
sampler: 'dpmpp_2m',
scheduler: 'karras',
});
});
// --- Category 4: scheduler-only ---
it('resolves scheduler-only values', () => {
expect(resolveSamplerScheduler('karras')).toEqual({ sampler: null, scheduler: 'karras' });
expect(resolveSamplerScheduler('simple')).toEqual({ sampler: null, scheduler: 'simple' });
expect(resolveSamplerScheduler('sgm_uniform')).toEqual({ sampler: null, scheduler: 'sgm_uniform' });
});
// --- Category 5: unrecognised / model-specific ---
it('returns null+null for unrecognised values', () => {
const result = resolveSamplerScheduler('AYS SDXL');
expect(result.sampler).toBeNull();
expect(result.scheduler).toBeNull();
});
it('returns null+null for Undefined', () => {
const result = resolveSamplerScheduler('Undefined');
expect(result.sampler).toBeNull();
expect(result.scheduler).toBeNull();
});
it('returns null+null for model-specific values', () => {
expect(resolveSamplerScheduler('Seedream-V45').sampler).toBeNull();
expect(resolveSamplerScheduler('GPT-Image-2').sampler).toBeNull();
});
// --- Category 6: edge cases ---
it('returns null+null for null / empty / whitespace', () => {
expect(resolveSamplerScheduler(null)).toEqual({ sampler: null, scheduler: null });
expect(resolveSamplerScheduler('')).toEqual({ sampler: null, scheduler: null });
expect(resolveSamplerScheduler(' ')).toEqual({ sampler: null, scheduler: null });
});
it('handles slash-separated custom format (extracts last segment)', () => {
// "multistep/dpmpp_2m_simple" — extracts last segment but the recursive
// call hits the "already internal name" regex before combined-name parsing,
// so it returns the raw segment as the sampler name.
const result = resolveSamplerScheduler('multistep/dpmpp_2m_simple');
expect(result.sampler).toBe('dpmpp_2m_simple');
expect(result.scheduler).toBeNull();
});
it('handles parse-error value (None', () => {
const result = resolveSamplerScheduler('(None');
expect(result.sampler).toBeNull();
expect(result.scheduler).toBeNull();
});
});
// ---------------------------------------------------------------------------
// findMatchingWidgets
// ---------------------------------------------------------------------------
describe('findMatchingWidgets', () => {
const resolved = {
seed: 42,
steps: 30,
cfg: 7,
sampler: 'euler_ancestral',
scheduler: 'karras',
};
it('matches seed to seed widget', () => {
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
expect(updates).toContainEqual({ widgetName: 'seed', value: '42' });
expect(updates).toContainEqual({ widgetName: 'steps', value: '30' });
expect(updates).toContainEqual({ widgetName: 'cfg', value: '7' });
expect(updates).toContainEqual({ widgetName: 'sampler_name', value: 'euler_ancestral' });
expect(updates).toContainEqual({ widgetName: 'scheduler', value: 'karras' });
});
it('skips undefined/null params', () => {
const updates = findMatchingWidgets(['seed', 'steps'], { seed: 42, steps: null, cfg: undefined });
expect(updates).toHaveLength(1);
expect(updates[0].widgetName).toBe('seed');
});
it('matches noise_seed when seed widget not present', () => {
const updates = findMatchingWidgets(['noise_seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
const seedUpdate = updates.find(u => u.widgetName === 'noise_seed');
expect(seedUpdate).toBeDefined();
expect(seedUpdate.value).toBe('42');
});
it('matches rgthree-style sampler widget name', () => {
const updates = findMatchingWidgets(['sampler', 'scheduler'], { sampler: 'euler', scheduler: 'karras' });
expect(updates).toContainEqual({ widgetName: 'sampler', value: 'euler' });
});
it('returns empty array for empty widget list', () => {
expect(findMatchingWidgets([], resolved)).toEqual([]);
expect(findMatchingWidgets(null, resolved)).toEqual([]);
});
it('handles case-insensitive widget name matching', () => {
const updates = findMatchingWidgets(['SEED', 'STEPS', 'CFG'], resolved);
expect(updates).toHaveLength(3);
});
it('returns updates in param order (seed, steps, cfg, sampler, scheduler)', () => {
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
expect(updates.map(u => u.widgetName)).toEqual(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler']);
});
});

View File

@@ -80,6 +80,8 @@ FALSE_POSITIVES = {
"array", "array",
"object", "object",
"non.existent.key", "non.existent.key",
"statistics.modelTypes.",
"statistics.",
} }
SPECIAL_UI_HELPER_KEYS = { SPECIAL_UI_HELPER_KEYS = {

View File

@@ -733,6 +733,65 @@ def test_lora_manager_cache_updates_when_loras_removed(metadata_registry):
assert "lora_node" not in metadata[LORAS] assert "lora_node" not in metadata[LORAS]
def test_lora_text_loader_extracts_loras_from_syntax(metadata_registry):
"""LoraTextLoaderLM extractor parses <lora:name:strength> tags from lora_syntax string."""
metadata_registry.start_collection("prompt1")
metadata_registry.record_node_execution(
"text_loader",
"LoraTextLoaderLM",
{"lora_syntax": ["<lora:foo:0.8> <lora:bar:1.0>"]},
None,
)
metadata = metadata_registry.get_metadata("prompt1")
assert "text_loader" in metadata[LORAS]
lora_list = metadata[LORAS]["text_loader"]["lora_list"]
assert len(lora_list) == 2
assert lora_list[0] == {"name": "foo", "strength": 0.8}
assert lora_list[1] == {"name": "bar", "strength": 1.0}
def test_lora_text_loader_extracts_loras_from_lora_stack(metadata_registry):
"""LoraTextLoaderLM extractor also processes the optional lora_stack input."""
metadata_registry.start_collection("prompt1")
metadata_registry.record_node_execution(
"stack_loader",
"LoraTextLoaderLM",
{
"lora_syntax": [""],
"lora_stack": (("/models/loras/my-lora.safetensors", 0.6, 0.5),),
},
None,
)
metadata = metadata_registry.get_metadata("prompt1")
assert "stack_loader" in metadata[LORAS]
lora_list = metadata[LORAS]["stack_loader"]["lora_list"]
assert len(lora_list) == 1
assert lora_list[0] == {"name": "my-lora", "strength": 0.6}
def test_lora_text_loader_handles_empty_syntax(metadata_registry):
"""LoraTextLoaderLM extractor produces no metadata when no loras are provided."""
metadata_registry.start_collection("prompt1")
metadata_registry.record_node_execution(
"empty_loader",
"LoraTextLoaderLM",
{"lora_syntax": [""]},
None,
)
metadata = metadata_registry.get_metadata("prompt1")
assert "empty_loader" not in metadata[LORAS]
def test_lora_manager_checkpoint_and_unet_loaders_extract_models(metadata_registry): def test_lora_manager_checkpoint_and_unet_loaders_extract_models(metadata_registry):
metadata_registry.start_collection("prompt1") metadata_registry.start_collection("prompt1")

View File

@@ -302,15 +302,15 @@ async def test_get_insights(stats_routes):
insights = payload["data"]["insights"] insights = payload["data"]["insights"]
assert len(insights) == 3 assert len(insights) == 3
titles = {entry["title"] for entry in insights} keys = {entry["key"] for entry in insights}
assert "High Number of Unused LoRAs" in titles assert "insights.unusedLoras.high" in keys
assert "Unused Checkpoints Detected" in titles assert "insights.unusedCheckpoints.detected" in keys
assert "High Number of Unused Embeddings" in titles assert "insights.unusedEmbeddings.high" in keys
descriptions = {entry["description"] for entry in insights} params_list = [entry["params"] for entry in insights]
assert any("2/3" in desc for desc in descriptions) assert any(p["total"] == "3" for p in params_list)
assert any("1/2" in desc for desc in descriptions) assert any(p["total"] == "2" for p in params_list)
assert any("1/1" in desc for desc in descriptions) assert any(p["total"] == "1" for p in params_list)
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -482,7 +482,7 @@ async def test_get_paginated_data_annotates_update_flags_with_bulk_dedup():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_flag_strategy_same_base_prefers_matching_base(): async def test_version_grouping_same_base_prefers_matching_base():
items = [ items = [
{ {
"model_name": "Pony Version", "model_name": "Pony Version",
@@ -551,7 +551,7 @@ async def test_update_flag_strategy_same_base_prefers_matching_base():
should_ignore_model=False, should_ignore_model=False,
) )
update_service = StubUpdateServiceWithRecords({1: record}) update_service = StubUpdateServiceWithRecords({1: record})
settings = StubSettings({"update_flag_strategy": "same_base"}) settings = StubSettings({"version_grouping": "same_base"})
service = DummyService( service = DummyService(
model_type="stub", model_type="stub",
@@ -579,7 +579,7 @@ async def test_update_flag_strategy_same_base_prefers_matching_base():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_flag_strategy_same_base_honors_latest_local_version(): async def test_version_grouping_same_base_honors_latest_local_version():
items = [ items = [
{ {
"model_name": "Pony v0.1", "model_name": "Pony v0.1",
@@ -648,7 +648,7 @@ async def test_update_flag_strategy_same_base_honors_latest_local_version():
should_ignore_model=False, should_ignore_model=False,
) )
update_service = StubUpdateServiceWithRecords({1: record}) update_service = StubUpdateServiceWithRecords({1: record})
settings = StubSettings({"update_flag_strategy": "same_base"}) settings = StubSettings({"version_grouping": "same_base"})
service = DummyService( service = DummyService(
model_type="stub", model_type="stub",
@@ -746,6 +746,264 @@ async def test_get_paginated_data_update_available_only_without_update_service()
assert response["total_pages"] == 0 assert response["total_pages"] == 0
@pytest.mark.asyncio
async def test_get_paginated_data_group_by_model_dedup():
"""group_by_model deduplicates items sharing the same civitai modelId,
keeping only the item with the highest version (civitai.id)."""
items = [
# Two versions of the same model (modelId=1)
{"model_name": "SameModel", "folder": "root", "civitai": {"modelId": 1, "id": 100}},
{"model_name": "SameModel", "folder": "root", "civitai": {"modelId": 1, "id": 200}},
# Another model with two versions
{"model_name": "AnotherModel", "folder": "root", "civitai": {"modelId": 2, "id": 50}},
{"model_name": "AnotherModel", "folder": "root", "civitai": {"modelId": 2, "id": 99}},
# A standalone item with no civitai metadata (no modelId)
{"model_name": "Standalone", "folder": "root"},
]
repository = StubRepository(items)
filter_set = PassThroughFilterSet()
search_strategy = NoSearchStrategy()
settings = StubSettings({})
service = DummyService(
model_type="stub",
scanner=object(),
metadata_class=BaseModelMetadata,
cache_repository=repository,
filter_set=filter_set,
search_strategy=search_strategy,
settings_provider=settings,
)
# With group_by_model=True — modelId=1 keeps id=200, modelId=2 keeps id=99
response = await service.get_paginated_data(
page=1,
page_size=10,
sort_by="name:asc",
group_by_model=True,
)
names = {item["model_name"] for item in response["items"]}
assert names == {"SameModel", "AnotherModel", "Standalone"}
assert response["total"] == 3
# Verify the kept items have the highest version id
for item in response["items"]:
if item.get("civitai", {}).get("modelId") == 1:
assert item["civitai"]["id"] == 200
# version_count should reflect total versions for this model
assert item.get("version_count") == 2, f"Expected version_count=2, got {item.get('version_count')}"
elif item.get("civitai", {}).get("modelId") == 2:
assert item["civitai"]["id"] == 99
assert item.get("version_count") == 2, f"Expected version_count=2, got {item.get('version_count')}"
else:
# Standalone item should NOT have version_count
assert "version_count" not in item, f"Standalone should not have version_count"
# With group_by_model=False (default) — all 5 items pass through
response_all = await service.get_paginated_data(
page=1,
page_size=10,
sort_by="name:asc",
)
assert response_all["total"] == 5
@pytest.mark.asyncio
async def test_get_paginated_data_versions_count_non_grouped_desc():
"""Non-grouped, versions_count:desc — groups by model, sorts by count desc,
within-group by version id desc, then flattens."""
items = [
# modelId=1 has 3 versions
{"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 300}},
{"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 200}},
{"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 100}},
# modelId=2 has 2 versions
{"model_name": "ModelB", "folder": "root", "civitai": {"modelId": 2, "id": 99}},
{"model_name": "ModelB", "folder": "root", "civitai": {"modelId": 2, "id": 50}},
# modelId=3 has 1 version
{"model_name": "ModelC", "folder": "root", "civitai": {"modelId": 3, "id": 1}},
# standalone (no modelId)
{"model_name": "Standalone", "folder": "root"},
]
repository = StubRepository(items)
filter_set = PassThroughFilterSet()
search_strategy = NoSearchStrategy()
settings = StubSettings({})
service = DummyService(
model_type="stub",
scanner=object(),
metadata_class=BaseModelMetadata,
cache_repository=repository,
filter_set=filter_set,
search_strategy=search_strategy,
settings_provider=settings,
)
response = await service.get_paginated_data(
page=1, page_size=10, sort_by="versions_count:desc",
)
ids = [item["civitai"]["id"] for item in response["items"] if "civitai" in item and "id" in item["civitai"]]
# modelId=1 (3 versions): id descending → 300, 200, 100
# modelId=2 (2 versions): id descending → 99, 50
# modelId=3 (1 version) → 1
assert ids == [300, 200, 100, 99, 50, 1], f"Unexpected order: {ids}"
assert response["total"] == 7
@pytest.mark.asyncio
async def test_get_paginated_data_versions_count_non_grouped_asc():
"""Non-grouped, versions_count:asc — groups by model, sorts by count asc,
then flattens."""
items = [
# modelId=1 has 3 versions
{"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 300}},
{"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 200}},
{"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 100}},
# modelId=2 has 2 versions
{"model_name": "ModelB", "folder": "root", "civitai": {"modelId": 2, "id": 99}},
{"model_name": "ModelB", "folder": "root", "civitai": {"modelId": 2, "id": 50}},
# modelId=3 has 1 version
{"model_name": "ModelC", "folder": "root", "civitai": {"modelId": 3, "id": 1}},
# standalone (no modelId)
{"model_name": "Standalone", "folder": "root"},
]
repository = StubRepository(items)
filter_set = PassThroughFilterSet()
search_strategy = NoSearchStrategy()
settings = StubSettings({})
service = DummyService(
model_type="stub",
scanner=object(),
metadata_class=BaseModelMetadata,
cache_repository=repository,
filter_set=filter_set,
search_strategy=search_strategy,
settings_provider=settings,
)
response = await service.get_paginated_data(
page=1, page_size=10, sort_by="versions_count:asc",
)
ids = [item["civitai"]["id"] for item in response["items"] if "civitai" in item and "id" in item["civitai"]]
# modelId=3 (1 version) → 1
# modelId=2 (2 versions): id descending → 99, 50
# modelId=1 (3 versions): id descending → 300, 200, 100
assert ids == [1, 99, 50, 300, 200, 100], f"Unexpected order: {ids}"
assert response["total"] == 7
@pytest.mark.asyncio
async def test_get_paginated_data_versions_count_non_grouped_same_base():
"""Non-grouped, versions_count with version_grouping=same_base —
models with same modelId but different base_model are separate groups."""
items = [
# modelId=1, base_model="sd15" — 2 versions
{"model_name": "ModelA", "folder": "root", "base_model": "sd15", "civitai": {"modelId": 1, "id": 200}},
{"model_name": "ModelA", "folder": "root", "base_model": "sd15", "civitai": {"modelId": 1, "id": 100}},
# modelId=1, base_model="sdxl" — 3 versions
{"model_name": "ModelA", "folder": "root", "base_model": "sdxl", "civitai": {"modelId": 1, "id": 30}},
{"model_name": "ModelA", "folder": "root", "base_model": "sdxl", "civitai": {"modelId": 1, "id": 20}},
{"model_name": "ModelA", "folder": "root", "base_model": "sdxl", "civitai": {"modelId": 1, "id": 10}},
# modelId=2, base_model="sd15" — 1 version
{"model_name": "ModelB", "folder": "root", "base_model": "sd15", "civitai": {"modelId": 2, "id": 1}},
]
repository = StubRepository(items)
filter_set = PassThroughFilterSet()
search_strategy = NoSearchStrategy()
settings = StubSettings({"version_grouping": "same_base"})
service = DummyService(
model_type="stub",
scanner=object(),
metadata_class=BaseModelMetadata,
cache_repository=repository,
filter_set=filter_set,
search_strategy=search_strategy,
settings_provider=settings,
)
response = await service.get_paginated_data(
page=1, page_size=10, sort_by="versions_count:desc",
)
ids = [item["civitai"]["id"] for item in response["items"] if "civitai" in item and "id" in item["civitai"]]
# (1, "sdxl") — 3 versions: 30, 20, 10
# (1, "sd15") — 2 versions: 200, 100
# (2, "sd15") — 1 version: 1
assert ids == [30, 20, 10, 200, 100, 1], f"Unexpected order: {ids}"
assert response["total"] == 6
async def test_get_paginated_data_filters_by_civitai_model_id():
"""civitai_model_id filter returns only items matching the given modelId,
and bypasses group_by_model dedup so all versions appear."""
items = [
# Two versions of modelId=1
{"model_name": "Model1_v1", "folder": "root", "civitai": {"modelId": 1, "id": 100}},
{"model_name": "Model1_v2", "folder": "root", "civitai": {"modelId": 1, "id": 200}},
# One version of modelId=2
{"model_name": "Model2", "folder": "root", "civitai": {"modelId": 2, "id": 50}},
# Standalone (no civitai data)
{"model_name": "Standalone", "folder": "root"},
]
repository = StubRepository(items)
filter_set = PassThroughFilterSet()
search_strategy = NoSearchStrategy()
settings = StubSettings({})
service = DummyService(
model_type="stub",
scanner=object(),
metadata_class=BaseModelMetadata,
cache_repository=repository,
filter_set=filter_set,
search_strategy=search_strategy,
settings_provider=settings,
)
# Filter by modelId=1 — both versions should appear
response = await service.get_paginated_data(
page=1,
page_size=10,
sort_by="name:asc",
civitai_model_id=1,
)
names = {item["model_name"] for item in response["items"]}
assert names == {"Model1_v1", "Model1_v2"}
assert response["total"] == 2
# Filter by modelId=2 — single version
response2 = await service.get_paginated_data(
page=1,
page_size=10,
sort_by="name:asc",
civitai_model_id=2,
)
assert response2["total"] == 1
assert response2["items"][0]["model_name"] == "Model2"
# civitai_model_id + group_by_model=True — still shows all versions (no dedup)
response_dedup = await service.get_paginated_data(
page=1,
page_size=10,
sort_by="name:asc",
civitai_model_id=1,
group_by_model=True,
)
assert response_dedup["total"] == 2
# Verify both versions are present (dedup was skipped)
version_ids = {item["civitai"]["id"] for item in response_dedup["items"]}
assert version_ids == {100, 200}
def test_model_filter_set_handles_include_and_exclude_tag_filters(): def test_model_filter_set_handles_include_and_exclude_tag_filters():
settings = StubSettings({}) settings = StubSettings({})
filter_set = ModelFilterSet(settings) filter_set = ModelFilterSet(settings)

View File

@@ -9,6 +9,7 @@ import pytest
from py.services.settings_manager import get_settings_manager from py.services.settings_manager import get_settings_manager
from py.utils.example_images_paths import ( from py.utils.example_images_paths import (
ensure_library_root_exists, ensure_library_root_exists,
find_non_compliant_items_in_example_images_root,
get_model_folder, get_model_folder,
get_model_relative_path, get_model_relative_path,
is_valid_example_images_root, is_valid_example_images_root,
@@ -140,3 +141,68 @@ def test_is_valid_example_images_root_accepts_legacy_library_structure(tmp_path,
(hash_folder / 'image.png').write_text('data', encoding='utf-8') (hash_folder / 'image.png').write_text('data', encoding='utf-8')
assert is_valid_example_images_root(str(tmp_path)) is True assert is_valid_example_images_root(str(tmp_path)) is True
def test_find_non_compliant_items_returns_empty_for_valid_root(tmp_path, settings_manager):
"""An empty folder or one with only hash dirs should return []."""
settings_manager.settings['example_images_path'] = str(tmp_path)
# Empty folder
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
# Only hash folders
hash_folder = tmp_path / ('f' * 64)
hash_folder.mkdir()
(hash_folder / 'image.png').write_text('data', encoding='utf-8')
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
def test_find_non_compliant_items_returns_offending_names(tmp_path, settings_manager):
"""A folder with non-hash items should return their names."""
settings_manager.settings['example_images_path'] = str(tmp_path)
# Create a valid hash folder so the root is otherwise acceptable
hash_folder = tmp_path / ('a' * 64)
hash_folder.mkdir()
# Add an offending file
(tmp_path / 'readme.txt').write_text('hello', encoding='utf-8')
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == ['readme.txt']
# Add an offending directory with content (empty dirs are accepted as
# potential legacy library folders by _library_folder_has_only_hash_dirs)
offending_dir = tmp_path / 'not_a_hash'
offending_dir.mkdir()
(offending_dir / 'some_file.txt').write_text('data', encoding='utf-8')
items = find_non_compliant_items_in_example_images_root(str(tmp_path))
assert 'readme.txt' in items
assert 'not_a_hash' in items
def test_find_non_compliant_items_ignores_hidden_files(tmp_path, settings_manager):
"""Hidden/system files should not appear in offending list."""
settings_manager.settings['example_images_path'] = str(tmp_path)
# .DS_Store is an allowed file
(tmp_path / '.DS_Store').write_text('', encoding='utf-8')
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
# Thumbs.db too
(tmp_path / 'Thumbs.db').write_text('', encoding='utf-8')
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
def test_find_non_compliant_items_accepts_download_progress_json(tmp_path, settings_manager):
""".download_progress.json should be recognised as a valid metadata file."""
settings_manager.settings['example_images_path'] = str(tmp_path)
(tmp_path / '.download_progress.json').write_text('{}', encoding='utf-8')
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
def test_find_non_compliant_items_reports_directory_error(tmp_path):
"""When the directory cannot be listed, return an explanatory message."""
non_existent = tmp_path / 'does-not-exist'
result = find_non_compliant_items_in_example_images_root(str(non_existent))
assert len(result) == 1
assert 'cannot list directory' in result[0]

View File

@@ -726,3 +726,25 @@ body.lm-lora-reordering * {
font-size: 12px; font-size: 12px;
color: rgba(226, 232, 240, 0.6); color: rgba(226, 232, 240, 0.6);
} }
/* ---- Widget flash highlight (visual cue after a value is sent to a node) ---- */
/* Applied to a widget row element when its value is updated by LoRA Manager.
Shifts the value text color to the LM brand accent with a CSS transition
for fade-in/fade-out. Removal (timeout / hover) is handled by JS.
The transition is declared on .lm-flash-host (added alongside .lm-flash)
rather than on ComfyUI's .lg-node-widget, so we don't impose a global
color transition on every widget input. The host class persists until
cleanup so fade-out still applies after .lm-flash is removed. */
.lm-flash-host input,
.lm-flash-host textarea,
.lm-flash-host [role="combobox"] {
transition: color 0.25s ease, -webkit-text-fill-color 0.25s ease;
}
.lm-flash input,
.lm-flash textarea,
.lm-flash [role="combobox"] {
color: #4299E0 !important;
-webkit-text-fill-color: #4299E0 !important;
}

126
web/comfyui/node_marker.js Normal file
View File

@@ -0,0 +1,126 @@
import { app } from "../../scripts/app.js";
// =============================================================================
// Node Marker right-click node marking (no dedicated node required)
//
// Adds a "🎯 Mark as →" submenu with role options to any node's context menu.
// Roles are stored in ``node.properties.lm_marker_role`` and automatically
// persist with the workflow JSON.
//
// The workflow registry reads these markers and makes them available to the
// standalone UI (e.g. ``sendEmbeddingToWorkflow`` also considers nodes marked
// as ``send_prompt_target``).
// =============================================================================
const ROLES = {
send_prompt_target: {
label: "Send Prompt Target",
emoji: "\uD83D\uDCDD",
},
send_gen_params: {
label: "Send Gen Params Target",
emoji: "\uD83D\uDD27",
},
};
// ---- Helpers ----------------------------------------------------------------
function getMarker(node) {
return node?.properties?.lm_marker_role ?? null;
}
function setMarker(node, roleKey) {
if (!node || !ROLES[roleKey]) return;
node.properties = node.properties || {};
node.properties.lm_marker_role = roleKey;
// Save original title if not already saved, then prefix with emoji
if (!node.properties.lm_marker_original_title) {
node.properties.lm_marker_original_title = node.title || "";
}
const def = ROLES[roleKey];
node.title = `${def.emoji} ${node.properties.lm_marker_original_title}`;
if (typeof node.setDirtyCanvas === "function") {
node.setDirtyCanvas(true, true);
}
triggerRegistryRefresh();
}
function clearMarker(node) {
if (!node) return;
delete node.properties.lm_marker_role;
// Restore original title: prefer stripping emoji from current title
// (captures user renames after marking), fall back to saved original.
const cleaned = node.title?.replace(
/^(\u2709\uFE0F?|\u2699\uFE0F?|\uD83D\uDCDD|\uD83C\uDF9B\uFE0F?|\uD83D\uDD27)\s*/,
''
);
if (cleaned && cleaned !== node.title) {
node.title = cleaned;
} else {
const orig = node.properties.lm_marker_original_title;
if (orig !== undefined) {
node.title = orig;
}
}
delete node.properties.lm_marker_original_title;
if (typeof node.setDirtyCanvas === "function") {
node.setDirtyCanvas(true, true);
}
triggerRegistryRefresh();
}
function triggerRegistryRefresh() {
// workflow_registry.js listens for this event to re-scan the graph.
window.dispatchEvent(new CustomEvent("lm_marker_changed"));
}
// ---- Submenu builder --------------------------------------------------------
function buildSubmenuOptions(node) {
const currentRole = getMarker(node);
const options = [];
for (const [key, def] of Object.entries(ROLES)) {
const isActive = currentRole === key;
options.push({
content: `${isActive ? "\u2713 " : ""}${def.label}`,
disabled: isActive,
callback: () => setMarker(node, key),
});
}
if (currentRole) {
options.push({
content: "Clear marker",
callback: () => clearMarker(node),
});
}
return options;
}
function buildMenuItems(node) {
return [
null,
{
content: "\uD83C\uDFAF Mark as",
has_submenu: true,
submenu: {
options: buildSubmenuOptions(node),
},
},
];
}
// ---- Extension --------------------------------------------------------------
app.registerExtension({
name: "LoraManager.NodeMarker",
getNodeMenuItems(node) {
return buildMenuItems(node);
},
});

View File

@@ -260,7 +260,6 @@ function createTagElement({
}) { }) {
const tagEl = document.createElement("div"); const tagEl = document.createElement("div");
tagEl.className = "comfy-tag"; tagEl.className = "comfy-tag";
tagEl.dataset.captureWheel = "true";
const baseStyles = { const baseStyles = {
padding: `${roundScaled(group ? 5 : 3, styleScale)}px ${roundScaled(group ? 8 : 10, styleScale)}px`, padding: `${roundScaled(group ? 5 : 3, styleScale)}px ${roundScaled(group ? 8 : 10, styleScale)}px`,
@@ -619,6 +618,36 @@ function showTagContextMenu(event, tagData, index, widget, anchorEl) {
setTimeout(() => document.addEventListener('click', closeMenu), 0); setTimeout(() => document.addEventListener('click', closeMenu), 0);
} }
// Singleton window capture-phase wheel hook: focuses the tags container when a
// wheel event occurs inside it, so that ComfyUI's wheelCapturedByFocusedElement
// recognises this zone and does NOT forward the event to canvas (which would
// trigger zoom and stopPropagation, preventing the strength-adjustment handler).
/** @type {boolean} */
let tagWheelCaptureHookInstalled = false;
function installTagWheelCaptureHook() {
if (tagWheelCaptureHookInstalled) return;
tagWheelCaptureHookInstalled = true;
window.addEventListener(
"wheel",
(event) => {
// Only handle vertical mouse wheel (not pinch-zoom or horizontal swipe)
if (event.ctrlKey || event.metaKey) return;
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
const target = /** @type {Element} */ (event.target);
if (!target?.closest) return;
const targetContainer = target.closest(
'.comfy-tags-container[data-capture-wheel="true"]'
);
if (!targetContainer) return;
targetContainer.focus({ preventScroll: true });
},
{ capture: true, passive: true }
);
}
export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.02, options = {}) { export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.02, options = {}) {
const container = document.createElement("div"); const container = document.createElement("div");
container.className = "comfy-tags-container"; container.className = "comfy-tags-container";
@@ -628,6 +657,29 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
forwardMiddleMouseToCanvas(container); forwardMiddleMouseToCanvas(container);
forwardWheelToCanvas(container); forwardWheelToCanvas(container);
// Vue render mode: ComfyUI's TransformPane uses a capture-phase wheel handler
// (TransformPane @wheel.capture) that checks wheelCapturedByFocusedElement.
// For that check to return true (preventing canvas zoom and allowing our
// strength-adjustment wheel handler to fire), the container needs both
// data-capture-wheel AND document.activeElement inside it.
// We make the container focusable and auto-focus it on wheel events via a
// window capture-phase hook.
container.dataset.captureWheel = "true";
container.tabIndex = -1;
// Blur on mouseleave to avoid lingering focus side effects.
container.addEventListener("mouseleave", () => {
if (document.activeElement === container) {
container.blur();
}
});
// Singleton window capture-phase wheel handler: focuses our container when
// a wheel event occurs inside it, so that wheelCapturedByFocusedElement
// recognises this zone and does NOT forward the event to canvas (which would
// trigger zoom and stopPropagation, preventing our strength handler).
installTagWheelCaptureHook();
Object.assign(container.style, { Object.assign(container.style, {
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",
@@ -641,6 +693,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
overflow: "auto", overflow: "auto",
alignItems: "flex-start", alignItems: "flex-start",
alignContent: "flex-start", alignContent: "flex-start",
outline: "none",
}); });
const initialTagsData = opts?.defaultVal || []; const initialTagsData = opts?.defaultVal || [];

View File

@@ -1,6 +1,7 @@
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import { getAllGraphNodes, getNodeReference, getNodeFromGraph } from "./utils.js"; import { getAllGraphNodes, getNodeReference, getNodeFromGraph } from "./utils.js";
import { ensureLmStyles } from "./lm_styles_loader.js";
const LORA_NODE_CLASSES = new Set([ const LORA_NODE_CLASSES = new Set([
"Lora Loader (LoraManager)", "Lora Loader (LoraManager)",
@@ -17,10 +18,68 @@ const TEXT_CAPABLE_CLASSES = new Set([
"CLIPTextEncode", "CLIPTextEncode",
]); ]);
/**
* Parse a hex color (#RGB or #RRGGBB) into an [r, g, b] tuple.
*/
function hexToRgb(hex) {
let h = hex.slice(1);
if (h.length === 3) {
h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
}
const n = parseInt(h, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
/**
* Linearly interpolate between two [r, g, b] tuples.
*/
function lerpColor(from, to, t) {
return [
Math.round(from[0] + (to[0] - from[0]) * t),
Math.round(from[1] + (to[1] - from[1]) * t),
Math.round(from[2] + (to[2] - from[2]) * t),
];
}
/**
* Run a short rAF-driven color fade on a canvas-drawn widget's text_color.
* Sets text_color to an interpolated rgb() string each frame. Returns a
* cancel function.
*
* @param widget the widget instance (must have a configurable text_color)
* @param fromColor [r, g, b] start color
* @param toColor [r, g, b] end color
* @param duration fade duration in ms
* @returns {function} cancel function — stops the fade immediately.
*/
function fadeWidgetTextColor(widget, fromColor, toColor, duration) {
let rafId = null;
const start = performance.now();
const tick = () => {
const elapsed = performance.now() - start;
const t = Math.min(1, elapsed / duration);
// Ease-out cubic for a smooth deceleration.
const eased = 1 - Math.pow(1 - t, 3);
const [r, g, b] = lerpColor(fromColor, toColor, eased);
Object.defineProperty(widget, 'text_color', {
value: `rgb(${r},${g},${b})`,
writable: true,
configurable: true,
});
if (t < 1) {
rafId = requestAnimationFrame(tick);
}
};
rafId = requestAnimationFrame(tick);
return () => { if (rafId) cancelAnimationFrame(rafId); };
}
app.registerExtension({ app.registerExtension({
name: "LoraManager.WorkflowRegistry", name: "LoraManager.WorkflowRegistry",
setup() { setup() {
ensureLmStyles();
api.addEventListener("lora_registry_refresh", () => { api.addEventListener("lora_registry_refresh", () => {
this.refreshRegistry(); this.refreshRegistry();
}); });
@@ -28,6 +87,11 @@ app.registerExtension({
api.addEventListener("lm_widget_update", (event) => { api.addEventListener("lm_widget_update", (event) => {
this.applyWidgetUpdate(event?.detail ?? {}); this.applyWidgetUpdate(event?.detail ?? {});
}); });
// React to marker changes from the Node Marker extension
window.addEventListener("lm_marker_changed", () => {
this.refreshRegistry();
});
}, },
async refreshRegistry() { async refreshRegistry() {
@@ -49,8 +113,10 @@ app.registerExtension({
const supportsLora = LORA_NODE_CLASSES.has(node.comfyClass); const supportsLora = LORA_NODE_CLASSES.has(node.comfyClass);
const hasTargetWidget = widgetNames.some((name) => TARGET_WIDGET_NAMES.has(name)); const hasTargetWidget = widgetNames.some((name) => TARGET_WIDGET_NAMES.has(name));
const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass); const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass);
const markerRole = node.properties?.lm_marker_role ?? null;
if (!supportsLora && !hasTargetWidget && !hasTextWidget) { // Skip nodes with no relevant capability UNLESS they are marked
if (!supportsLora && !hasTargetWidget && !hasTextWidget && !markerRole) {
continue; continue;
} }
@@ -71,6 +137,7 @@ app.registerExtension({
type: node.comfyClass, type: node.comfyClass,
comfy_class: node.comfyClass, comfy_class: node.comfyClass,
mode: node.mode, mode: node.mode,
marker_role: markerRole,
capabilities: { capabilities: {
supports_lora: supportsLora, supports_lora: supportsLora,
has_text_widget: hasTextWidget, has_text_widget: hasTextWidget,
@@ -102,11 +169,12 @@ app.registerExtension({
applyWidgetUpdate(message) { applyWidgetUpdate(message) {
const nodeId = message?.node_id ?? message?.id; const nodeId = message?.node_id ?? message?.id;
const graphId = message?.graph_id; const graphId = message?.graph_id;
const action = message?.action;
const widgetName = message?.widget_name; const widgetName = message?.widget_name;
const value = message?.value; const value = message?.value;
const mode = message?.mode ?? "replace"; const mode = message?.mode ?? "replace";
if (nodeId == null || !widgetName) { if (nodeId == null || (!action && !widgetName)) {
console.warn("LoRA Manager: invalid widget update payload", message); console.warn("LoRA Manager: invalid widget update payload", message);
return; return;
} }
@@ -126,33 +194,72 @@ app.registerExtension({
return; return;
} }
const widgetIndex = node.widgets.findIndex((widget) => widget?.name === widgetName); // ---- Resolve target widget ----
if (widgetIndex === -1) { let targetWidget = null;
console.warn(
"LoRA Manager: target widget not found on node", if (action === "inject_text") {
widgetName, // Find the first text-capable widget by type.
node // Normalise to lowercase for case-insensitive matching.
); const TEXT_TYPES = new Set(["string", "customtext"]);
targetWidget = node.widgets.find((w) => {
const t = typeof w?.type === "string" ? w.type.toLowerCase() : "";
if (TEXT_TYPES.has(t)) return true;
// Broad fallback for unknown composite types.
if (t.includes("string")) {
return true;
}
return false;
});
if (!targetWidget) {
// Last resort: pick the first widget that is not a hidden/internal type
targetWidget = node.widgets.find((w) => w?.name && !w.name.startsWith("_"));
if (!targetWidget) {
console.warn(
"LoRA Manager: no suitable widget for inject_text on node",
node.id
);
return;
}
}
} else if (widgetName) {
// Legacy: find widget by name
targetWidget = node.widgets.find((w) => w?.name === widgetName);
if (!targetWidget) {
console.warn(
"LoRA Manager: target widget not found on node",
widgetName,
node
);
return;
}
} else {
console.warn("LoRA Manager: no action or widget_name in payload", message);
return; return;
} }
const widget = node.widgets[widgetIndex]; // ---- Update widget value ----
const widgetIndex = node.widgets.indexOf(targetWidget);
let newValue = value; let newValue = value;
if (mode === "append") { if (mode === "append") {
const separator = widget.value && widget.value.length > 0 ? " " : ""; const separator =
newValue = widget.value + separator + value; targetWidget.value && targetWidget.value.length > 0 ? " " : "";
newValue = targetWidget.value + separator + value;
} }
widget.value = newValue; targetWidget.value = newValue;
if (Array.isArray(node.widgets_values) && node.widgets_values.length > widgetIndex) { if (
Array.isArray(node.widgets_values) &&
widgetIndex >= 0 &&
node.widgets_values.length > widgetIndex
) {
node.widgets_values[widgetIndex] = newValue; node.widgets_values[widgetIndex] = newValue;
} }
if (typeof widget.callback === "function") { if (typeof targetWidget.callback === "function") {
try { try {
widget.callback(newValue); targetWidget.callback(newValue);
} catch (callbackError) { } catch (callbackError) {
console.error("LoRA Manager: widget callback failed", callbackError); console.error("LoRA Manager: widget callback failed", callbackError);
} }
@@ -165,5 +272,296 @@ app.registerExtension({
if (typeof app.graph?.setDirtyCanvas === "function") { if (typeof app.graph?.setDirtyCanvas === "function") {
app.graph.setDirtyCanvas(true, true); app.graph.setDirtyCanvas(true, true);
} }
// ---- Visual cue: briefly highlight the updated widget ----
this.flashWidget(node, targetWidget);
},
/**
* Add a temporary visual highlight to a widget after its value is updated.
*
* Both rendering modes shift the value text color to the LM brand accent
* (#4299E0) with a fade-in/fade-out, then restore it after FLASH_DURATION
* (3s) or on hover:
* - Vue Nodes mode: add a `.lm-flash` class to the widget row. CSS
* `transition: color 0.25s` handles fade-in/out. A MutationObserver
* re-applies the class if Vue re-renders the row during the flash.
* - Canvas mode: DOM widgets (customtext/autocomplete) use inline
* `transition` for fade; canvas-drawn widgets (combo/number/toggle) use
* a short rAF color interpolation for fade-in (250ms) and fade-out
* (400ms). A low-frequency poll checks hover dismissal via
* app.canvas.getWidgetAtCursor().
*/
flashWidget(node, widget) {
const FLASH_DURATION = 3000;
const FADE_IN_MS = 250;
const VALUE_COLOR = '#4299E0'; // LM brand accent — consistent with selection/border/drop-indicator
const nodeId = node.id;
// ---- Vue Nodes mode: CSS class for value text color ----
const nodeEl = document.querySelector(`[data-node-id="${nodeId}"]`);
if (nodeEl) {
this._flashVueWidget(nodeEl, widget, node, {
FLASH_DURATION, VALUE_COLOR,
});
return;
}
// ---- Canvas mode ----
this._flashCanvasWidget(node, widget, {
FLASH_DURATION, FADE_IN_MS, VALUE_COLOR,
});
},
/**
* Vue/DOM flash: add `.lm-flash` class to the widget row for the value text
* color shift. Re-applies on re-render via MutationObserver. Removes on
* timeout / hover.
*/
_flashVueWidget(nodeEl, widget, graphNode, { FLASH_DURATION, VALUE_COLOR }) {
const FLASH_CLASS = 'lm-flash';
// Find the widget row in the DOM. Vue renders widget rows as
// [data-testid="node-widget"] elements whose order matches node.widgets[].
// Match strategy (in priority order):
// 1. By label text via [data-testid="widget-layout-field-label"] (combo/number/toggle)
// 2. By <label> text (CLIPTextEncode customtext has a bare <label>)
// 3. By widget index — graph node.widgets[i] ↔ Nth DOM row (text widgets
// like Prompt LM have no label at all, so index is the only stable match)
const widgetIndex = Array.isArray(graphNode?.widgets)
? graphNode.widgets.indexOf(widget)
: -1;
const findRowEl = () => {
const rows = nodeEl.querySelectorAll('[data-testid="node-widget"]');
// Strategy 1: data-testid label
for (const r of rows) {
const label = r.querySelector('[data-testid="widget-layout-field-label"]');
if (label && label.textContent.trim() === widget.name) {
return r;
}
}
// Strategy 2: bare <label> element
for (const r of rows) {
const label = r.querySelector('label');
if (label && label.textContent.trim() === widget.name) {
return r;
}
}
// Strategy 3: index match
if (widgetIndex >= 0 && widgetIndex < rows.length) {
return rows[widgetIndex];
}
return null;
};
let cleanedUp = false;
const cleanupFns = [];
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
for (const fn of cleanupFns) {
try { fn(); } catch (e) { /* ignore */ }
}
// Remove .lm-flash to trigger the CSS color fade-out. Keep
// .lm-flash-host (which carries the transition rule) until the
// fade-out completes, then remove it.
const row = findRowEl();
if (row) {
row.classList.remove(FLASH_CLASS);
// Remove the host class after the transition completes.
setTimeout(() => {
const r = findRowEl();
if (r) r.classList.remove('lm-flash-host');
}, 300);
}
};
// Initial application
const apply = () => {
const row = findRowEl();
if (row && !row.classList.contains(FLASH_CLASS)) {
// Restart the animation by toggling the class off-on.
row.classList.remove(FLASH_CLASS);
// Force reflow so the animation restarts.
void row.offsetWidth;
row.classList.add('lm-flash-host');
row.classList.add(FLASH_CLASS);
}
};
apply();
// Re-apply if Vue re-renders and drops the class.
const observer = new MutationObserver(() => {
if (cleanedUp) return;
apply();
});
observer.observe(nodeEl, { childList: true, subtree: true });
cleanupFns.push(() => observer.disconnect());
// Hard timeout: remove the class after FLASH_DURATION.
const timeoutId = setTimeout(cleanup, FLASH_DURATION + 100);
cleanupFns.push(() => clearTimeout(timeoutId));
// Hover dismissal: clear the flash when the user mouses over the row.
const onHover = (e) => {
const row = findRowEl();
if (row && row.contains(e.target)) {
cleanup();
}
};
nodeEl.addEventListener('mouseover', onHover);
cleanupFns.push(() => nodeEl.removeEventListener('mouseover', onHover));
},
/**
* Canvas flash: set text_color (canvas-drawn widgets) and inline color
* (DOM widgets). Canvas-drawn widgets get a rAF-driven color fade-in/out;
* DOM widgets use CSS transition. A low-frequency poll checks hover
* dismissal via app.canvas.getWidgetAtCursor().
*/
_flashCanvasWidget(node, widget, { FLASH_DURATION, FADE_IN_MS, VALUE_COLOR }) {
const FADE_OUT_MS = 400;
const FADE_OUT_START = FLASH_DURATION - FADE_OUT_MS;
const DEFAULT_RGB = hexToRgb('#DDD'); // LiteGraph WIDGET_TEXT_COLOR
const FLASH_RGB = hexToRgb(VALUE_COLOR);
/**
* Check whether a widget is a DOM-based widget (customtext / autocomplete)
* that renders a real <textarea>/<input> element rather than being
* canvas-drawn. Evaluated per-widget so batch cleanup handles each
* widget correctly regardless of when it was added to the batch.
*/
const isDomWidget = (w) =>
(w.inputEl && (w.inputEl.tagName === 'TEXTAREA' || w.inputEl.tagName === 'INPUT'))
|| !!w.element?.querySelector?.('textarea, input');
/**
* Get the DOM element for a DOM-based widget.
*/
const getDomEl = (w) =>
(w.inputEl && (w.inputEl.tagName === 'TEXTAREA' || w.inputEl.tagName === 'INPUT'))
? w.inputEl
: w.element?.querySelector?.('textarea, input') ?? null;
// --- Track fade-out cancellers per widget so batch cleanup can stop
// any in-progress fade for ALL widgets in the batch, not just the
// latest one. ---
if (!node._lmFadeCancels) node._lmFadeCancels = {};
// --- DOM widget color (customtext / autocomplete text) ---
// CSS transition handles both fade-in and fade-out automatically.
if (isDomWidget(widget)) {
const domEl = getDomEl(widget);
if (domEl) {
domEl.style.transition = `color ${FADE_IN_MS}ms ease`;
domEl.style.color = VALUE_COLOR;
}
}
// --- Canvas-drawn widget: kick off fade-in via rAF ---
if (!isDomWidget(widget)) {
// Set immediately to start (rAF will refine from first frame).
Object.defineProperty(widget, 'text_color', {
value: VALUE_COLOR,
writable: true,
configurable: true,
});
const cancel = fadeWidgetTextColor(widget, DEFAULT_RGB, FLASH_RGB, FADE_IN_MS);
node._lmFadeCancels[widget.name] = cancel;
}
// --- Track flashed widgets for batch cleanup ---
if (!node._lmFlashedWidgets) node._lmFlashedWidgets = [];
if (!node._lmFlashedWidgets.includes(widget)) {
node._lmFlashedWidgets.push(widget);
}
// --- Track fade-out scheduling per widget ---
if (!node._lmFadeOutTimers) node._lmFadeOutTimers = {};
if (typeof node.setDirtyCanvas === 'function') {
node.setDirtyCanvas(true);
}
// --- Poll for hover dismissal ---
let pollId = null;
let cleanedUp = false;
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
if (pollId) clearInterval(pollId);
pollId = null;
for (const w of (node._lmFlashedWidgets || [])) {
// Cancel any pending fade-out timer for this widget
if (node._lmFadeOutTimers?.[w.name]) {
clearTimeout(node._lmFadeOutTimers[w.name]);
delete node._lmFadeOutTimers[w.name];
}
// Cancel any in-progress fade-in/out rAF for this widget
if (node._lmFadeCancels?.[w.name]) {
node._lmFadeCancels[w.name]();
delete node._lmFadeCancels[w.name];
}
delete w.text_color;
delete w.secondary_text_color;
// Clear DOM widget inline color first (transition plays the
// fade-out), then remove the transition property after it
// completes. Keeping transition until then is essential.
if (isDomWidget(w)) {
const el = getDomEl(w);
if (el) {
el.style.color = '';
// Remove the transition property after the fade completes.
setTimeout(() => {
if (el) el.style.transition = '';
}, 300);
}
}
}
delete node._lmFlashedWidgets;
delete node._lmFadeOutTimers;
delete node._lmFadeCancels;
delete node._lmFlashCleanup;
if (typeof node.setDirtyCanvas === 'function') {
node.setDirtyCanvas(true);
}
};
// Schedule fade-out for canvas-drawn widgets only (DOM widgets fade
// automatically when we clear the inline color in cleanup).
if (!isDomWidget(widget)) {
// Clear any previous fade-out timer for this widget
if (node._lmFadeOutTimers[widget.name]) {
clearTimeout(node._lmFadeOutTimers[widget.name]);
}
node._lmFadeOutTimers[widget.name] = setTimeout(() => {
if (cleanedUp) return;
const cancel = fadeWidgetTextColor(widget, FLASH_RGB, DEFAULT_RGB, FADE_OUT_MS);
node._lmFadeCancels[widget.name] = cancel;
delete node._lmFadeOutTimers[widget.name];
}, FADE_OUT_START);
}
// Low-frequency poll (~100ms) for hover dismissal.
const checkHover = () => {
if (cleanedUp) return;
const canvas = window.app?.canvas;
if (canvas) {
const hovered = canvas.getWidgetAtCursor?.();
if (hovered && node._lmFlashedWidgets?.includes(hovered)) {
cleanup();
return;
}
}
};
pollId = setInterval(checkHover, 100);
// Hard timeout fallback.
if (node._lmFlashCleanup) clearTimeout(node._lmFlashCleanup);
node._lmFlashCleanup = setTimeout(cleanup, FLASH_DURATION + 50);
}, },
}); });