Compare commits

...

52 Commits
v1.0.9 ... main

Author SHA1 Message Date
Will Miao
451f74b874 fix(ui): return minWidth/minHeight from autocomplete text widget factory for proper node initial sizing 2026-06-09 15:21:45 +08:00
pixelpaws
a1d248baa6 Merge pull request #966 from willmiao/design-token-system-phase4
Design token system phase4
2026-06-09 14:37:02 +08:00
Will Miao
18577fa336 refactor(phase-4): standardize remaining transitions and box-shadows
- Replace all remaining 'transition: all' with specific token-based transitions
- Replace 80+ hardcoded box-shadow rgba values with semantic tokens
- Add new tokens: --shadow-side, --shadow-elevated, --shadow-dialog, --shadow-inset-top
- Update dark theme overrides for new shadow tokens
- 32 files changed, net +8 lines (more consistent, less duplication)
2026-06-09 14:27:53 +08:00
Will Miao
5797ce9408 feat(phase-4): visual polish — font stack, shadow system, transitions, micro-interactions
Phase 4: Visual Polish

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Single URL behavior is preserved unchanged.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 11:37:36 +08:00
114 changed files with 4305 additions and 898 deletions

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -7,9 +7,9 @@
],
"allSupporters": [
"Insomnia Art Designs",
"2018cfh",
"megakirbs",
"Brennok",
"2018cfh",
"W+K+White",
"wackop",
"Phil",
@@ -17,56 +17,67 @@
"Arlecchino Shion",
"Charles Blakemore",
"Rob Williams",
"$MetaSamsara",
"stone9k",
"itismyelement",
"$MetaSamsara",
"onesecondinosaur",
"Rosenthal",
"Francisco Tatis",
"Tobi_Swagg",
"Andrew Wilson",
"Greybush",
"Ricky Carter",
"JongWon Han",
"VantAI",
"runte3221",
"FreelancerZ",
"Edgar Tejeda",
"Fraser Cross",
"Liam MacDougal",
"Polymorphic Indeterminate",
"Marc Whiffen",
"Skalabananen",
"Birdy",
"Kiba",
"Mozzel",
"itismyelement",
"Gingko Biloba",
"Reno Lam",
"onesecondinosaur",
"sig",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"J\\B/ 8r0wns0n",
"Snaggwort",
"Takkan",
"Matt+J",
"ClockDaemon",
"KD",
"Omnidex",
"Tyler Trebuchon",
"Release Cabrakan",
"Tobi_Swagg",
"SG",
"carozzz",
"James Dooley",
"zenbound",
"Buzzard",
"jmack",
"Andrew Wilson",
"Greybush",
"Adam Shaw",
"Mark Corneglio",
"SarcasticHashtag",
"Anthony Rizzo",
"iamresist",
"RedrockVP",
"Wolffen",
"Ricky Carter",
"James Todd",
"Steven Pfeiffer",
"VantAI",
"Tim",
"Timmy",
"Johnny",
"Lisster",
"Michael Wong",
"Illrigger",
"whudunit",
"Tom Corrigan",
"JackieWang",
"fnkylove",
@@ -77,16 +88,16 @@
"Robert Stacey",
"PM",
"Todd Keck",
"Edgar Tejeda",
"Briton Heilbrun",
"Jorge Hussni",
"Liam MacDougal",
"Sterilized",
"BadassArabianMofo",
"Pascal Dahle",
"quarz",
"Greg",
"JSST",
"Snaggwort",
"lmsupporter",
"zounic",
"wfpearl",
"Baekdoosixt",
"Jonathan Ross",
@@ -99,29 +110,25 @@
"contrite831",
"Alex",
"bh",
"carozzz",
"Marlon Daniels",
"Starkselle",
"Aaron Bleuer",
"LacesOut!",
"greebles",
"Adam Shaw",
"Anthony Rizzo",
"M Postkasse",
"Gooohokrbe",
"RedrockVP",
"Wicked Choices by ASLPro3D",
"OldBones",
"Jacob Hoehler",
"FinalyFree",
"Weasyl",
"Timmy",
"Johnny",
"Lex Song",
"Cory Paza",
"Tak",
"Gonzalo Andre Allendes Lopez",
"Zach Gonser",
"Big Red",
"whudunit",
"Jimmy Ledbetter",
"Luc Job",
"dl0901dm",
"Philip Hempel",
@@ -129,13 +136,13 @@
"Nick Walker",
"Bishoujoker",
"aai",
"Briton Heilbrun",
"Tori",
"wildnut",
"jean jahren",
"Aleksander Wujczyk",
"AM Kuro",
"Pascal Dahle",
"Ran C",
"ViperC",
"Penfore",
"Sangheili460",
"MagnaInsomnia",
@@ -148,32 +155,35 @@
"The Spawn",
"graysock",
"Greenmoustache",
"zounic",
"fancypants",
"Eldithor",
"Joboshy",
"Digital",
"JaxMax",
"takyamtom",
"Bohemian Corporal",
"Dan",
"Jwk0205",
"Bro Xie",
"yer fey",
"batblue",
"carey6409",
"Olive",
"太郎 ゲーム",
"Some Guy Named Barry",
"jinxedx",
"Cosmosis",
"AELOX",
"Dankin",
"Nicfit23",
"FloPro4Sho",
"wamekukyouzin",
"drum matthieu",
"Dogmaster",
"Matt Wenzel",
"Lex Song",
"Frank Nitty",
"Christopher Michel",
"Gonzalo Andre Allendes Lopez",
"Serge Bekenkamp",
"Jimmy Ledbetter",
"LeoZero",
"Antonio Pontes",
"ApathyJones",
@@ -182,11 +192,12 @@
"nahinahi9",
"Dustin Chen",
"dan",
"Blackfish95",
"Mouthlessman",
"Paul Kroll",
"otaku fra",
"ViperC",
"Ran C",
"MiraiKuriyamaSy",
"Bas Imagineer",
"yuxz69",
"Adam Taylor",
"Weird_With_A_Beard",
@@ -202,25 +213,25 @@
"Jon Sandman",
"Ubivis",
"CloudValley",
"thesoftwaredruid",
"wundershark",
"mr_dinosaur",
"Tyrswood",
"linnfrey",
"IamAyam",
"skaterb949",
"Joboshy",
"Bohemian Corporal",
"Dan",
"Josef Lanzl",
"confiscated Zyra",
"yer fey",
"Error_Rule34_Not_found",
"Gerald Welly",
"Roslynd",
"Tee Gee",
"jinxedx",
"Geolog",
"tarek helmi",
"Neco28",
"Max Marklund",
"David Ortega",
"Dankin",
"Cristian Vazquez",
"Frank Nitty",
"Magic Noob",
"Pronredn",
"DougPeterson",
@@ -230,22 +241,17 @@
"Kevin John Duck",
"conner",
"Kevin Christopher",
"Blackfish95",
"dd",
"Princess Bright Eyes",
"Paul Kroll",
"Dušan Ryban",
"Felipe dos Santos",
"Bas Imagineer",
"John Statham",
"Douglas Gaspar",
"Metryman55",
"AlexDuKaNa",
"George",
"dw",
"decoy",
"thesoftwaredruid",
"wundershark",
"mr_dinosaur",
"Tyrswood",
"Ray Wing",
"Ranzitho",
"Gus",
@@ -254,6 +260,7 @@
"David LaVallee",
"ae",
"Tr4shP4nda",
"Gamalonia",
"WRL_SPR",
"capn",
"Joseph",
@@ -262,9 +269,12 @@
"Piccio08",
"kumakichi",
"cppbel",
"Moon Knight",
"몽타주",
"Kland",
"Hailshem",
"奚明 刘",
"Brian M",
"Josef Lanzl",
"Nerezza",
"sanborondon",
"준희 김",
@@ -272,16 +282,15 @@
"aezin",
"Thought2Form",
"jcay015",
"Gerald Welly",
"Kevin Picco",
"Erik Lopez",
"Mateo Curić",
"Geolog",
"Eris3D",
"Tomohiro Baba",
"m",
"Noora",
"Pierce McBride",
"Joshua Gray",
"Mattssn",
"Mikko Hemilä",
"Jamie Ogletree",
@@ -295,7 +304,6 @@
"CryptoTraderJK",
"Yuji Kaneko",
"Davaitamin",
"Dušan Ryban",
"Rops Alot",
"tedcor",
"Sam",
@@ -303,16 +311,10 @@
"sjon kreutz",
"Ace Ventura",
"MadSpin",
"Metryman55",
"inbijiburu",
"Nick “Loadstone” D",
"Gamalonia",
"momokai",
"starbugx",
"Moon Knight",
"몽타주",
"Kland",
"Hailshem",
"kudari",
"Naomi Hale Danchi",
"dc7431",
@@ -333,6 +335,10 @@
"JohnDoe42054",
"BillyHill",
"emyth",
"chriphost",
"KitKatM",
"socrasteeze",
"OrganicArtifact",
"Vir",
"gzmzmvp",
"Richard",
@@ -350,8 +356,9 @@
"Ivan Tadic",
"Mike Simone",
"ethanfel",
"Joshua Gray",
"Elliot E",
"Morgandel",
"Theerat Jiramate",
"Focuschannel",
"Noah",
"Jacob McDaniel",
@@ -365,11 +372,14 @@
"battu",
"Michael Anthony Scott",
"Atilla Berke Pekduyar",
"Nathan",
"Decx _",
"Pat Hen",
"Jordan Shaw",
"Srdb",
"四糸凜音",
"Nihongasuki",
"LarsesFPC",
"JC",
"Prompt Pirate",
"uwutismxd",
@@ -377,17 +387,14 @@
"zenobeus",
"Crocket",
"Jackthemind",
"chriphost",
"KitKatM",
"ryoma",
"socrasteeze",
"OrganicArtifact",
"Stryker",
"ResidentDeviant",
"MudkipMedkitz",
"deanbrian",
"Alex Wortman",
"Cody",
"Raku",
"smart.edge5178",
"InformedViewz",
"CHKeeho80",
@@ -401,6 +408,7 @@
"moonpetal",
"SomeDude",
"g9p0o",
"Pkrsky",
"TheHolySheep",
"raf8osz",
"Monte Won",
@@ -408,6 +416,7 @@
"carsten",
"ikok",
"ElitaSSJ4",
"David Schenck",
"Wolfe7D1",
"blikkies",
"Chris",
@@ -419,16 +428,15 @@
"Zude",
"John J Linehan",
"Kyler",
"Elliot E",
"Theerat Jiramate",
"Edward Kennedy",
"Justin Blaylock",
"aRtFuL_DodGeR",
"Nick Kage",
"Vane Holzer",
"psytrax",
"Cyrus Fett",
"hexxish",
"notedfakes",
"Nathan",
"Billy Gladky",
"NICHOLAS BAXLEY",
"Michael Scott",
@@ -436,7 +444,7 @@
"Ed Wang",
"Wes Sims",
"ItsGeneralButtNaked",
"SRDB",
"Donor4115",
"g unit",
"Distortik",
"Filippo Ferrari",
@@ -453,10 +461,11 @@
"Whitepinetrader",
"POPPIN",
"Ginnie",
"Raku",
"emadsultan",
"Pkrsky",
"nanana",
"g",
"J",
"Alan+Cano",
"FeralOpticsAI",
"Pavlaki",
"Doug+Rintoul",
@@ -473,13 +482,12 @@
"Duk3+Rand0m",
"Nathen+Choi",
"T",
"LarsesFPC",
"cocona",
"Buecyb99",
"Welkor",
"David Schenck",
"John Martin",
"Ink Temptation",
"JBsuede",
"moranqianlong",
"Kalli Core",
"Time Valentine",
@@ -489,10 +497,8 @@
"SPJ",
"Kyron Mahan",
"Bryan Rutkowski",
"Nick Kage",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Cyrus Fett",
"Ezokewn",
"SendingRavens",
"Xenon Xue",
@@ -506,7 +512,7 @@
"Jacob Winter",
"Ryan Presley Ng",
"jinksta187",
"Donor4115",
"Andrew Wilkinson",
"Manu Thetug",
"Karlanx",
"Lyavph",
@@ -531,6 +537,8 @@
"Scott",
"Muratoraccio",
"D",
"low9",
"Winged",
"YassineKhaled",
"Y",
"MatteKey",
@@ -551,9 +559,6 @@
"redcarrot",
"powerbot99",
"Fthehappy",
"rsamerica",
"sfasdfasfdsa",
"Alan+Cano",
"generic404",
"abattoirblues",
"zounik",
@@ -562,7 +567,8 @@
"ahoystan",
"Bob Barker",
"edk",
"JBsuede",
"Tú Nguyễn Lý Hoàng",
"Ronan Delevacq",
"Christian Schäfer",
"りん あめ",
"ja s",
@@ -580,6 +586,7 @@
"Boba Smith",
"Devil Lude",
"David Murcko",
"MR.Bear",
"Jack Dole",
"max blo",
"Sauv",
@@ -593,10 +600,11 @@
"Kevin Wallace",
"Jimmy Borup",
"ChicRic",
"Tigon",
"BastardSama",
"mercur",
"Pete Pain",
"RHopkirk",
"Andrew Wilkinson",
"Yavizu3d",
"Maxim",
"Yves Poezevara",
@@ -647,6 +655,9 @@
"SelfishMedic",
"adderleighn",
"EnragedAntelope",
"SRCRCOSS",
"imer",
"Akkas+Haque",
"Kachac",
"tyrant2811",
"Kevin",
@@ -678,8 +689,6 @@
"Terraformer",
"GDS+DEV",
"4rt+r3d",
"low9",
"Winged",
"you+halo9",
"Somebody",
"Somebody",
@@ -696,21 +705,22 @@
"Obsidian.Studios",
"han b",
"Zomba Mann",
"Aquaneo",
"Nico",
"Maximilian Krischan",
"Banana Joe",
"_ G3n",
"Donovan Jenkins",
"Hans Meier",
"Tú Nguyễn Lý Hoàng",
"shira1011",
"sicarius",
"Michael Eid",
"beersandbacon",
"Neko Desco",
"Bob barker",
"Ben D",
"Ninja Tom",
"G",
"Ronan Delevacq",
"karim ben brik",
"Vinarus",
"Michael Zhu",
@@ -735,8 +745,7 @@
"AZ Party Oasis",
"Adictedtohumping",
"Towelie",
"Ryan Smith",
"MR.Bear",
"TheFusion",
"matt",
"dsffsdfsdfsdfsdfsdf",
"somethingtosay8",
@@ -745,6 +754,7 @@
"Terminuz",
"Kurt",
"ivistorm",
"Matt M.",
"Ivan Imes",
"Faburizu",
"Jack Lawfield",
@@ -763,12 +773,13 @@
"Rizzi",
"nimin",
"OMAR LUCIANO",
"Somebody",
"CoffeeMage",
"Ken+Suzuki",
"hannibal",
"Jo+Example",
"BrentBertram",
"inusanorthcape",
"Tigon",
"eumelzocker",
"dxjaymz",
"L C",
@@ -776,5 +787,5 @@
"Somebody",
"CK"
],
"totalCount": 773
"totalCount": 784
}

View File

@@ -16,7 +16,9 @@
"help": "Hilfe",
"add": "Hinzufügen",
"close": "Schließen",
"menu": "Menü"
"menu": "Menü",
"remove": "Entfernen",
"change": "Ändern"
},
"status": {
"loading": "Wird geladen...",
@@ -963,6 +965,13 @@
"empty": {
"noFolders": "Keine Ordner gefunden",
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
},
"folderUpdateCheck": {
"label": "Auf Updates in diesem Ordner prüfen",
"loading": "Prüfe {type}-Updates in diesem Ordner...",
"success": "{count} Update(s) für {type}s in diesem Ordner gefunden",
"none": "Alle {type}s in diesem Ordner sind aktuell",
"error": "Fehler beim Prüfen des Ordners auf {type}-Updates: {message}"
}
},
"statistics": {
@@ -1007,9 +1016,9 @@
"download": {
"title": "Modell von URL herunterladen",
"titleWithType": "{type} von URL herunterladen",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Geben Sie eine CivitAI- oder CivArchive-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
"locationPreview": "Download-Speicherort Vorschau",
"useDefaultPath": "Standardpfad verwenden",
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
@@ -1031,6 +1040,11 @@
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
"alreadyInLibrary": "Bereits in Bibliothek",
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
"fileSelection": {
"title": "Dateiformat auswählen",
"files": "Dateien",
"select": "Datei auswählen"
},
"errors": {
"invalidUrl": "Ungültiges Civitai URL-Format",
"noVersions": "Keine Versionen für dieses Modell verfügbar"
@@ -1213,7 +1227,9 @@
},
"notes": {
"saved": "Notizen erfolgreich gespeichert",
"saveFailed": "Fehler beim Speichern der Notizen"
"saveFailed": "Fehler beim Speichern der Notizen",
"showMore": "Mehr anzeigen",
"showLess": "Weniger anzeigen"
},
"usageTips": {
"addPresetParameter": "Voreingestellten Parameter hinzufügen...",
@@ -1656,6 +1672,10 @@
"noRecipeId": "Keine Rezept-ID verfügbar",
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",
"createError": "Fehler beim Erstellen des Rezepts{message}",
"createFailed": "Fehler beim Erstellen des Rezepts{error}",
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
"created": "Rezept erfolgreich erstellt",
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
"preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download",

View File

@@ -16,7 +16,9 @@
"help": "Help",
"add": "Add",
"close": "Close",
"menu": "Menu"
"menu": "Menu",
"remove": "Remove",
"change": "Change"
},
"status": {
"loading": "Loading...",
@@ -963,6 +965,13 @@
"empty": {
"noFolders": "No folders found",
"dragHint": "Drag items here to create folders"
},
"folderUpdateCheck": {
"label": "Check for updates in this folder",
"loading": "Checking {type} updates for this folder...",
"success": "Found {count} update(s) for {type}s in this folder",
"none": "All {type}s in this folder are up to date",
"error": "Failed to check folder for {type} updates: {message}"
}
},
"statistics": {
@@ -1007,9 +1016,9 @@
"download": {
"title": "Download Model from URL",
"titleWithType": "Download {type} from URL",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL:",
"civitaiUrl": "Civitai URL(s):",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Enter one CivitAI or CivArchive URL per line. Supports multiple URLs for batch download.",
"locationPreview": "Download Location Preview",
"useDefaultPath": "Use Default Path",
"useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates",
@@ -1031,6 +1040,11 @@
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
"alreadyInLibrary": "Already in Library",
"autoOrganizedPath": "[Auto-organized by path template]",
"fileSelection": {
"title": "Select File Format",
"files": "files",
"select": "Select File"
},
"errors": {
"invalidUrl": "Invalid Civitai URL format",
"noVersions": "No versions available for this model"
@@ -1213,7 +1227,9 @@
},
"notes": {
"saved": "Notes saved successfully",
"saveFailed": "Failed to save notes"
"saveFailed": "Failed to save notes",
"showMore": "Show more",
"showLess": "Show less"
},
"usageTips": {
"addPresetParameter": "Add preset parameter...",
@@ -1656,6 +1672,10 @@
"noRecipeId": "No recipe ID available",
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
"copyFailed": "Error copying recipe syntax: {message}",
"createError": "Error creating recipe: {message}",
"createFailed": "Failed to create recipe: {error}",
"createMissingData": "Missing required data to create recipe",
"created": "Recipe created successfully",
"noMissingLoras": "No missing LoRAs to download",
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
"preparingForDownloadFailed": "Error preparing LoRAs for download",

View File

@@ -16,7 +16,9 @@
"help": "Ayuda",
"add": "Añadir",
"close": "Cerrar",
"menu": "Menú"
"menu": "Menú",
"remove": "Eliminar",
"change": "Cambiar"
},
"status": {
"loading": "Cargando...",
@@ -963,6 +965,13 @@
"empty": {
"noFolders": "No se encontraron carpetas",
"dragHint": "Arrastra elementos aquí para crear carpetas"
},
"folderUpdateCheck": {
"label": "Buscar actualizaciones en esta carpeta",
"loading": "Buscando actualizaciones de {type} en esta carpeta...",
"success": "Se encontraron {count} actualización(es) para {type}s en esta carpeta",
"none": "Todos los {type}s en esta carpeta están actualizados",
"error": "Error al buscar actualizaciones de {type} en la carpeta: {message}"
}
},
"statistics": {
@@ -1007,9 +1016,9 @@
"download": {
"title": "Descargar modelo desde URL",
"titleWithType": "Descargar {type} desde URL",
"url": "URL de Civitai",
"civitaiUrl": "URL de Civitai:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Ingrese una URL de CivitAI o CivArchive por línea. Admite múltiples URLs para descarga por lotes.",
"locationPreview": "Vista previa de ubicación de descarga",
"useDefaultPath": "Usar ruta predeterminada",
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
@@ -1031,6 +1040,11 @@
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
"alreadyInLibrary": "Ya en la biblioteca",
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
"fileSelection": {
"title": "Seleccionar formato de archivo",
"files": "archivos",
"select": "Seleccionar archivo"
},
"errors": {
"invalidUrl": "Formato de URL de Civitai inválido",
"noVersions": "No hay versiones disponibles para este modelo"
@@ -1213,7 +1227,9 @@
},
"notes": {
"saved": "Notas guardadas exitosamente",
"saveFailed": "Error al guardar notas"
"saveFailed": "Error al guardar notas",
"showMore": "Mostrar más",
"showLess": "Mostrar menos"
},
"usageTips": {
"addPresetParameter": "Añadir parámetro preestablecido...",
@@ -1656,6 +1672,10 @@
"noRecipeId": "No hay ID de receta disponible",
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}",
"copyFailed": "Error copiando sintaxis de receta: {message}",
"createError": "Error al crear la receta{message}",
"createFailed": "Error al crear la receta{error}",
"createMissingData": "Faltan datos necesarios para crear la receta",
"created": "Receta creada exitosamente",
"noMissingLoras": "No hay LoRAs faltantes para descargar",
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
"preparingForDownloadFailed": "Error preparando LoRAs para descarga",

View File

@@ -16,7 +16,9 @@
"help": "Aide",
"add": "Ajouter",
"close": "Fermer",
"menu": "Menu"
"menu": "Menu",
"remove": "Supprimer",
"change": "Modifier"
},
"status": {
"loading": "Chargement...",
@@ -963,6 +965,13 @@
"empty": {
"noFolders": "Aucun dossier trouvé",
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
},
"folderUpdateCheck": {
"label": "Vérifier les mises à jour dans ce dossier",
"loading": "Vérification des mises à jour {type} dans ce dossier...",
"success": "{count} mise(s) à jour trouvée(s) pour les {type}s dans ce dossier",
"none": "Tous les {type}s dans ce dossier sont à jour",
"error": "Échec de la vérification des mises à jour {type} dans ce dossier : {message}"
}
},
"statistics": {
@@ -1007,9 +1016,9 @@
"download": {
"title": "Télécharger un modèle depuis une URL",
"titleWithType": "Télécharger {type} depuis une URL",
"url": "URL Civitai",
"civitaiUrl": "URL Civitai :",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Entrez une URL CivitAI ou CivArchive par ligne. Prend en charge plusieurs URLs pour le téléchargement par lot.",
"locationPreview": "Aperçu de l'emplacement de téléchargement",
"useDefaultPath": "Utiliser le chemin par défaut",
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
@@ -1031,6 +1040,11 @@
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
"alreadyInLibrary": "Déjà dans la bibliothèque",
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
"fileSelection": {
"title": "Choisir le format de fichier",
"files": "fichiers",
"select": "Choisir le fichier"
},
"errors": {
"invalidUrl": "Format d'URL Civitai invalide",
"noVersions": "Aucune version disponible pour ce modèle"
@@ -1213,7 +1227,9 @@
},
"notes": {
"saved": "Notes sauvegardées avec succès",
"saveFailed": "Échec de la sauvegarde des notes"
"saveFailed": "Échec de la sauvegarde des notes",
"showMore": "Afficher plus",
"showLess": "Afficher moins"
},
"usageTips": {
"addPresetParameter": "Ajouter un paramètre prédéfini...",
@@ -1656,6 +1672,10 @@
"noRecipeId": "Aucun ID de recipe disponible",
"sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}",
"copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}",
"createError": "Erreur lors de la création du Recipe {message}",
"createFailed": "Échec de la création du Recipe {error}",
"createMissingData": "Données requises manquantes pour créer le Recipe",
"created": "Recipe créé avec succès",
"noMissingLoras": "Aucun LoRA manquant à télécharger",
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
"preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement",

View File

@@ -16,7 +16,9 @@
"help": "עזרה",
"add": "הוספה",
"close": "סגור",
"menu": "תפריט"
"menu": "תפריט",
"remove": "הסר",
"change": "שנה"
},
"status": {
"loading": "טוען...",
@@ -963,6 +965,13 @@
"empty": {
"noFolders": "לא נמצאו תיקיות",
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
},
"folderUpdateCheck": {
"label": "בדוק עדכונים בתיקייה זו",
"loading": "בודק עדכוני {type} בתיקייה זו...",
"success": "נמצאו {count} עדכון/ים עבור {type}s בתיקייה זו",
"none": "כל ה-{type}s בתיקייה זו מעודכנים",
"error": "נכשל בבדיקת עדכוני {type} בתיקייה: {message}"
}
},
"statistics": {
@@ -1007,9 +1016,9 @@
"download": {
"title": "הורד מודל מכתובת URL",
"titleWithType": "הורד {type} מכתובת URL",
"url": "כתובת URL של Civitai",
"civitaiUrl": "כתובת URL של Civitai:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "יש להזין כתובת URL אחת של CivitAI או CivArchive בכל שורה. תומך במספר כתובות URL להורדה בבת אחת.",
"locationPreview": "תצוגה מקדימה של מיקום ההורדה",
"useDefaultPath": "השתמש בנתיב ברירת מחדל",
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
@@ -1031,6 +1040,11 @@
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
"alreadyInLibrary": "כבר בספרייה",
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
"fileSelection": {
"title": "בחר פורמט קובץ",
"files": "קבצים",
"select": "בחר קובץ"
},
"errors": {
"invalidUrl": "פורמט URL של Civitai לא חוקי",
"noVersions": "אין גרסאות זמינות למודל זה"
@@ -1213,7 +1227,9 @@
},
"notes": {
"saved": "הערות נשמרו בהצלחה",
"saveFailed": "שמירת ההערות נכשלה"
"saveFailed": "שמירת ההערות נכשלה",
"showMore": "הצג עוד",
"showLess": "הצג פחות"
},
"usageTips": {
"addPresetParameter": "הוסף פרמטר קבוע מראש...",
@@ -1656,6 +1672,10 @@
"noRecipeId": "אין מזהה מתכון זמין",
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
"createError": "שגיאה ביצירת המתכון:{message}",
"createFailed": "יצירת המתכון נכשלה:{error}",
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
"created": "המתכון נוצר בהצלחה",
"noMissingLoras": "אין LoRAs חסרים להורדה",
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
"preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה",

View File

@@ -16,7 +16,9 @@
"help": "ヘルプ",
"add": "追加",
"close": "閉じる",
"menu": "メニュー"
"menu": "メニュー",
"remove": "削除",
"change": "変更"
},
"status": {
"loading": "読み込み中...",
@@ -963,6 +965,13 @@
"empty": {
"noFolders": "フォルダが見つかりません",
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
},
"folderUpdateCheck": {
"label": "このフォルダのアップデートを確認",
"loading": "このフォルダの{type}アップデートを確認中...",
"success": "このフォルダの{type}sに{count}件のアップデートが見つかりました",
"none": "このフォルダのすべての{type}sは最新です",
"error": "フォルダの{type}アップデート確認に失敗しました: {message}"
}
},
"statistics": {
@@ -1007,9 +1016,9 @@
"download": {
"title": "URLからモデルをダウンロード",
"titleWithType": "URLから{type}をダウンロード",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL",
"placeholder": "https://civitai.com/models/...",
"urlHint": "1行に1つのCivitAIまたはCivArchive URLを入力してください。複数のURLを一括ダウンロードできます。",
"locationPreview": "ダウンロード場所プレビュー",
"useDefaultPath": "デフォルトパスを使用",
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
@@ -1031,6 +1040,11 @@
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
"alreadyInLibrary": "既にライブラリ内",
"autoOrganizedPath": "[パステンプレートによる自動整理]",
"fileSelection": {
"title": "ファイル形式を選択",
"files": "ファイル",
"select": "ファイルを選択"
},
"errors": {
"invalidUrl": "無効なCivitai URL形式",
"noVersions": "このモデルの利用可能なバージョンがありません"
@@ -1213,7 +1227,9 @@
},
"notes": {
"saved": "メモが正常に保存されました",
"saveFailed": "メモの保存に失敗しました"
"saveFailed": "メモの保存に失敗しました",
"showMore": "もっと見る",
"showLess": "折りたたむ"
},
"usageTips": {
"addPresetParameter": "プリセットパラメータを追加...",
@@ -1656,6 +1672,10 @@
"noRecipeId": "レシピIDが利用できません",
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
"copyFailed": "レシピ構文のコピーエラー:{message}",
"createError": "レシピ作成中にエラーが発生しました:{message}",
"createFailed": "レシピの作成に失敗しました:{error}",
"createMissingData": "レシピ作成に必要なデータが不足しています",
"created": "レシピを作成しました",
"noMissingLoras": "ダウンロードする不足LoRAがありません",
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",

View File

@@ -16,7 +16,9 @@
"help": "도움말",
"add": "추가",
"close": "닫기",
"menu": "메뉴"
"menu": "메뉴",
"remove": "제거",
"change": "변경"
},
"status": {
"loading": "로딩 중...",
@@ -963,6 +965,13 @@
"empty": {
"noFolders": "폴더를 찾을 수 없습니다",
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
},
"folderUpdateCheck": {
"label": "이 폴더의 업데이트 확인",
"loading": "이 폴더의 {type} 업데이트를 확인하는 중...",
"success": "이 폴더에서 {type}s에 대한 {count}개 업데이트를 찾았습니다",
"none": "이 폴더의 모든 {type}s가 최신 상태입니다",
"error": "폴더의 {type} 업데이트 확인 실패: {message}"
}
},
"statistics": {
@@ -1007,9 +1016,9 @@
"download": {
"title": "URL에서 모델 다운로드",
"titleWithType": "URL에서 {type} 다운로드",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "한 줄에 하나의 CivitAI 또는 CivArchive URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
"locationPreview": "다운로드 위치 미리보기",
"useDefaultPath": "기본 경로 사용",
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
@@ -1031,6 +1040,11 @@
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
"alreadyInLibrary": "이미 라이브러리에 있음",
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
"fileSelection": {
"title": "파일 형식 선택",
"files": "개 파일",
"select": "파일 선택"
},
"errors": {
"invalidUrl": "잘못된 Civitai URL 형식",
"noVersions": "이 모델에 사용 가능한 버전이 없습니다"
@@ -1213,7 +1227,9 @@
},
"notes": {
"saved": "메모가 성공적으로 저장됨",
"saveFailed": "메모 저장 실패"
"saveFailed": "메모 저장 실패",
"showMore": "더 보기",
"showLess": "접기"
},
"usageTips": {
"addPresetParameter": "프리셋 매개변수 추가...",
@@ -1656,6 +1672,10 @@
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
"copyFailed": "레시피 문법 복사 오류: {message}",
"createError": "레시피 생성 중 오류 발생:{message}",
"createFailed": "레시피 생성 실패:{error}",
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
"created": "레시피가 생성되었습니다",
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류",

View File

@@ -16,7 +16,9 @@
"help": "Справка",
"add": "Добавить",
"close": "Закрыть",
"menu": "Меню"
"menu": "Меню",
"remove": "Удалить",
"change": "Изменить"
},
"status": {
"loading": "Загрузка...",
@@ -963,6 +965,13 @@
"empty": {
"noFolders": "Папки не найдены",
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
},
"folderUpdateCheck": {
"label": "Проверить обновления в этой папке",
"loading": "Проверка обновлений {type} в этой папке...",
"success": "Найдено {count} обновление(й) для {type}s в этой папке",
"none": "Все {type}s в этой папке актуальны",
"error": "Не удалось проверить папку на наличие обновлений {type}: {message}"
}
},
"statistics": {
@@ -1007,9 +1016,9 @@
"download": {
"title": "Скачать модель по URL",
"titleWithType": "Скачать {type} по URL",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Введите один URL CivitAI или CivArchive в каждой строке. Поддерживается пакетная загрузка нескольких URL.",
"locationPreview": "Предпросмотр места загрузки",
"useDefaultPath": "Использовать путь по умолчанию",
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
@@ -1031,6 +1040,11 @@
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
"alreadyInLibrary": "Уже в библиотеке",
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
"fileSelection": {
"title": "Выбрать формат файла",
"files": "файлов",
"select": "Выбрать файл"
},
"errors": {
"invalidUrl": "Неверный формат URL Civitai",
"noVersions": "Нет доступных версий для этой модели"
@@ -1213,7 +1227,9 @@
},
"notes": {
"saved": "Заметки успешно сохранены",
"saveFailed": "Не удалось сохранить заметки"
"saveFailed": "Не удалось сохранить заметки",
"showMore": "Показать больше",
"showLess": "Свернуть"
},
"usageTips": {
"addPresetParameter": "Добавить предустановленный параметр...",
@@ -1656,6 +1672,10 @@
"noRecipeId": "ID рецепта недоступен",
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
"createError": "Ошибка при создании рецепта:{message}",
"createFailed": "Не удалось создать рецепт:{error}",
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
"created": "Рецепт успешно создан",
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
"preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки",

View File

@@ -16,7 +16,9 @@
"help": "帮助",
"add": "添加",
"close": "关闭",
"menu": "菜单"
"menu": "菜单",
"remove": "移除",
"change": "更换"
},
"status": {
"loading": "加载中...",
@@ -963,6 +965,13 @@
"empty": {
"noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹"
},
"folderUpdateCheck": {
"label": "检查此文件夹的更新",
"loading": "正在检查此文件夹中的{type}更新...",
"success": "在此文件夹中找到 {count} 个{type}更新",
"none": "此文件夹中的所有{type}都是最新版本",
"error": "检查文件夹{type}更新失败: {message}"
}
},
"statistics": {
@@ -1007,9 +1016,9 @@
"download": {
"title": "从 URL 下载模型",
"titleWithType": "从 URL 下载 {type}",
"url": "Civitai URL",
"civitaiUrl": "Civitai URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "每行输入一个 CivitAI 或 CivArchive URL。支持批量下载多个 URL。",
"locationPreview": "下载位置预览",
"useDefaultPath": "使用默认路径",
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
@@ -1031,6 +1040,11 @@
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
"alreadyInLibrary": "已存在于库中",
"autoOrganizedPath": "【已按路径模板自动整理】",
"fileSelection": {
"title": "选择文件格式",
"files": "个文件",
"select": "选择文件"
},
"errors": {
"invalidUrl": "无效的 Civitai URL 格式",
"noVersions": "此模型没有可用版本"
@@ -1213,7 +1227,9 @@
},
"notes": {
"saved": "备注保存成功",
"saveFailed": "备注保存失败"
"saveFailed": "备注保存失败",
"showMore": "展开",
"showLess": "收起"
},
"usageTips": {
"addPresetParameter": "添加预设参数...",
@@ -1656,6 +1672,10 @@
"noRecipeId": "无配方 ID",
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
"copyFailed": "复制配方语法出错:{message}",
"createError": "创建配方时出错:{message}",
"createFailed": "创建配方失败:{error}",
"createMissingData": "缺少创建配方所需的数据",
"created": "配方创建成功",
"noMissingLoras": "没有缺失的 LoRA 可下载",
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
"preparingForDownloadFailed": "准备下载 LoRA 时出错",

View File

@@ -16,7 +16,9 @@
"help": "說明",
"add": "新增",
"close": "關閉",
"menu": "選單"
"menu": "選單",
"remove": "移除",
"change": "更換"
},
"status": {
"loading": "載入中...",
@@ -963,6 +965,13 @@
"empty": {
"noFolders": "未找到資料夾",
"dragHint": "將項目拖到此處以建立資料夾"
},
"folderUpdateCheck": {
"label": "檢查此資料夾的更新",
"loading": "正在檢查此資料夾中的{type}更新...",
"success": "在此資料夾中找到 {count} 個{type}更新",
"none": "此資料夾中的所有{type}都是最新版本",
"error": "檢查資料夾{type}更新失敗: {message}"
}
},
"statistics": {
@@ -1007,9 +1016,9 @@
"download": {
"title": "從網址下載模型",
"titleWithType": "從網址下載 {type}",
"url": "Civitai 網址",
"civitaiUrl": "Civitai 網址:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "每行輸入一個 CivitAI 或 CivArchive URL。支援批量下載多個 URL。",
"locationPreview": "下載位置預覽",
"useDefaultPath": "使用預設路徑",
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
@@ -1031,6 +1040,11 @@
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
"alreadyInLibrary": "已在庫存",
"autoOrganizedPath": "[依路徑範本自動整理]",
"fileSelection": {
"title": "選擇檔案格式",
"files": "個檔案",
"select": "選擇檔案"
},
"errors": {
"invalidUrl": "Civitai 網址格式無效",
"noVersions": "此模型無可用版本"
@@ -1213,7 +1227,9 @@
},
"notes": {
"saved": "備註已儲存",
"saveFailed": "儲存備註失敗"
"saveFailed": "儲存備註失敗",
"showMore": "展開",
"showLess": "收起"
},
"usageTips": {
"addPresetParameter": "新增預設參數...",
@@ -1656,6 +1672,10 @@
"noRecipeId": "無配方 ID",
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
"copyFailed": "複製配方語法錯誤:{message}",
"createError": "建立配方時發生錯誤:{message}",
"createFailed": "建立配方失敗:{error}",
"createMissingData": "缺少建立配方所需的資料",
"created": "配方建立成功",
"noMissingLoras": "無缺少的 LoRA 可下載",
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",

View File

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

View File

@@ -190,27 +190,42 @@ class RecipeEnricher:
existing_cp = recipe.get("checkpoint")
if existing_cp is None:
existing_cp = {}
# Extract baseModel from raw civitai_info before populate_checkpoint_from_civitai
# (populate may reject non-checkpoint types and lose this data)
base_model_from_civitai: str = ""
if isinstance(civitai_info, dict):
base_model_from_civitai = civitai_info.get("baseModel", "") or ""
elif isinstance(civitai_info, tuple) and len(civitai_info) > 0 and isinstance(civitai_info[0], dict):
base_model_from_civitai = civitai_info[0].get("baseModel", "") or ""
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
# 1. First, resolve base_model using full data before we format it away
# 1. Resolve base_model from checkpoint_data first, then fall back to raw civitai_info
current_base_model = recipe.get("base_model")
resolved_base_model = checkpoint_data.get("baseModel")
resolved_base_model = checkpoint_data.get("baseModel") or base_model_from_civitai
if resolved_base_model:
# Update if empty OR if it matches our generic prefix but is less specific
is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"]
if is_generic and resolved_base_model != current_base_model:
recipe["base_model"] = resolved_base_model
# 2. Format according to requirements: type, modelId, modelVersionId, modelName, modelVersionName
formatted_checkpoint = {
"type": "checkpoint",
"modelId": checkpoint_data.get("modelId"),
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
"modelName": checkpoint_data.get("name"), # In base.py, 'name' is populated from civitai_data['model']['name']
"modelVersionName": checkpoint_data.get("version") # In base.py, 'version' is populated from civitai_data['name']
}
# Remove None values
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
# 2. Only format and save checkpoint if it has real data (not just type after type rejection)
has_checkpoint_data = any([
checkpoint_data.get("modelId"),
checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
checkpoint_data.get("name"),
checkpoint_data.get("version"),
])
if has_checkpoint_data:
formatted_checkpoint = {
"type": "checkpoint",
"modelId": checkpoint_data.get("modelId"),
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
"modelName": checkpoint_data.get("name"),
"modelVersionName": checkpoint_data.get("version"),
}
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
return True
else:
# Fallback to name extraction if we don't already have one

View File

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

View File

@@ -1472,6 +1472,21 @@ class ModelDownloadHandler:
)
return web.Response(status=500, text=str(exc))
async def skip_download_get(self, request: web.Request) -> web.Response:
try:
download_id = request.query.get("download_id")
if not download_id:
return web.json_response(
{"success": False, "error": "Download ID is required"}, status=400
)
result = await self._download_coordinator.skip_download(download_id)
return web.json_response(result)
except Exception as exc:
self._logger.error(
"Error skipping download via GET: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def cancel_download_get(self, request: web.Request) -> web.Response:
try:
download_id = request.query.get("download_id")
@@ -1960,6 +1975,10 @@ class ModelUpdateHandler:
if target_model_ids:
target_model_ids = sorted(set(target_model_ids))
folder_path: Optional[str] = payload.get("folder_path")
if folder_path is not None and not isinstance(folder_path, str):
folder_path = None
provider = await self._get_civitai_provider()
if provider is None:
return web.json_response(
@@ -1974,6 +1993,7 @@ class ModelUpdateHandler:
provider,
force_refresh=force_refresh,
target_model_ids=target_model_ids or None,
folder_path=folder_path,
)
if self._service.scanner.is_cancelled():
return web.json_response(
@@ -1996,10 +2016,21 @@ class ModelUpdateHandler:
self._logger.error("Failed to refresh model updates: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
hide_early_access = False
if self._settings is not None:
try:
hide_early_access = bool(
self._settings.get("hide_early_access_updates", False)
)
except Exception:
pass
serialized_records = []
for record in records.values():
has_update_fn = getattr(record, "has_update", None)
if callable(has_update_fn) and has_update_fn():
if callable(has_update_fn) and has_update_fn(
hide_early_access=hide_early_access
):
serialized_records.append(self._serialize_record(record))
return web.json_response(
@@ -2561,6 +2592,7 @@ class ModelHandlerSet:
"download_model": self.download.download_model,
"download_model_get": self.download.download_model_get,
"cancel_download_get": self.download.cancel_download_get,
"skip_download_get": self.download.skip_download_get,
"pause_download_get": self.download.pause_download_get,
"resume_download_get": self.download.resume_download_get,
"get_download_progress": self.download.get_download_progress,

View File

@@ -16,7 +16,7 @@ from aiohttp import web
from ...config import config
from ...services.server_i18n import server_i18n as default_server_i18n
from ...services.settings_manager import SettingsManager
from ...services.settings_manager import SettingsManager, get_settings_manager
from ...services.recipes import (
RecipeAnalysisService,
RecipeDownloadError,
@@ -26,7 +26,12 @@ from ...services.recipes import (
RecipeValidationError,
)
from ...services.metadata_service import get_default_metadata_provider
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
from ...utils.civitai_utils import (
build_civitai_image_page_url,
extract_civitai_image_id,
extract_civitai_image_id_from_cdn_url,
rewrite_preview_url,
)
from ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher
@@ -96,6 +101,7 @@ class RecipeHandlerSet:
"browse_directory": self.batch_import.browse_directory,
"check_image_exists": self.management.check_image_exists,
"import_from_url": self.management.import_from_url,
"create_from_example": self.management.create_from_example,
}
@@ -461,7 +467,11 @@ class RecipeQueryHandler:
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
self._logger.info("Manually triggering recipe cache rebuild")
full_rebuild = request.query.get("full_rebuild", "true").lower() == "true"
self._logger.info(
"Manually triggering recipe cache %s",
"full rebuild" if full_rebuild else "refresh",
)
await recipe_scanner.get_cached_data(force_refresh=True)
return web.json_response(
{"success": True, "message": "Recipe cache refreshed successfully"}
@@ -975,6 +985,9 @@ class RecipeManagementHandler:
civitai_model = civitai_parsed.get("model")
if civitai_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = civitai_model
civitai_base_model = civitai_parsed.get("base_model")
if civitai_base_model and not metadata.get("base_model"):
metadata["base_model"] = civitai_base_model
elif parsed_embedded:
parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"):
@@ -982,6 +995,8 @@ class RecipeManagementHandler:
parsed_model = parsed_embedded.get("model")
if parsed_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = parsed_model
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
metadata["base_model"] = parsed_embedded["base_model"]
civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe(
@@ -1489,25 +1504,28 @@ class RecipeManagementHandler:
if not image_url:
raise RecipeValidationError("Missing required field: image_url")
force = request.query.get("force", "false").lower() == "true"
image_id = extract_civitai_image_id(image_url)
if not image_id:
raise RecipeValidationError(
"Could not extract Civitai image ID from URL"
)
# Check for duplicate (fast, before acquiring semaphore)
cache = await recipe_scanner.get_cached_data()
for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path")
if source:
existing_id = extract_civitai_image_id(source)
if existing_id == image_id:
return web.json_response({
"success": True,
"recipe_id": recipe.get("id"),
"name": recipe.get("title", ""),
"already_exists": True,
})
# Check for duplicate (fast, before acquiring semaphore), unless force
if not force:
cache = await recipe_scanner.get_cached_data()
for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path")
if source:
existing_id = extract_civitai_image_id(source)
if existing_id == image_id:
return web.json_response({
"success": True,
"recipe_id": recipe.get("id"),
"name": recipe.get("title", ""),
"already_exists": True,
})
async with self._import_semaphore:
return await self._do_import_from_url(image_url, recipe_scanner)
@@ -1613,6 +1631,9 @@ class RecipeManagementHandler:
civitai_model = civitai_parsed.get("model")
if civitai_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = civitai_model
civitai_base_model = civitai_parsed.get("base_model")
if civitai_base_model and not metadata.get("base_model"):
metadata["base_model"] = civitai_base_model
elif parsed_embedded:
parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"):
@@ -1620,6 +1641,8 @@ class RecipeManagementHandler:
parsed_model = parsed_embedded.get("model")
if parsed_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = parsed_model
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
metadata["base_model"] = parsed_embedded["base_model"]
civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe(
@@ -1651,6 +1674,272 @@ class RecipeManagementHandler:
)
return web.json_response(result.payload, status=result.status)
async def create_from_example(self, request: web.Request) -> web.Response:
"""Create a recipe from a model's example image using cached metadata.
Uses the image's meta data (already cached in .metadata.json from the
CivitAI model-versions API) to create a recipe without additional
CivitAI API calls.
If the image metadata doesn't contain any resources of the parent
model's type (LoRA-type or Checkpoint), the parent model is
auto-populated as a fallback.
Request body:
image_data (dict): The full image object from model-versions API
(includes meta, additionalResources, url, etc.)
model_hash (str): SHA256 hash of the parent model
model_name (str): Filename of the parent model
model_type (str): Page type (``"loras"``, ``"checkpoints"``, etc.)
local_image_path (str, optional): Local filesystem path to read
the image bytes for the recipe preview
"""
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
data = await request.json()
image_data = data.get("image_data")
model_hash = data.get("model_hash")
model_name = data.get("model_name")
model_type = data.get("model_type", "")
if not image_data or not model_hash or not model_name:
raise RecipeValidationError(
"Missing required fields: image_data, model_hash, model_name"
)
# Merge nested meta into top level so the parser finds everything.
# CivitaiApiMetadataParser expects prompt, seed, resources, etc.
# at the top level or wrapped under a "meta" key.
inner_meta = image_data.get("meta") or {}
parsed_input = {**image_data, **inner_meta}
parsed_input.pop("meta", None)
# Build a local cache of {hash → cache_item} so the parser can
# skip CivitAI API calls for models that exist on disk.
local_cache: Dict[str, Dict[str, Any]] = {}
lora_scanner = getattr(recipe_scanner, "_lora_scanner", None)
if lora_scanner and model_hash:
try:
parent_cache_data = await lora_scanner.get_cached_data()
for item in getattr(parent_cache_data, "raw_data", []):
if item.get("sha256", "").lower() == model_hash.lower():
local_cache[model_hash.lower()] = item
# Compute AutoV3 so the parser can also match on
# that hash type (CivitAI metadata resources use
# AutoV3).
file_path = item.get("file_path")
if file_path and os.path.exists(file_path):
try:
from ...utils.file_utils import (
calculate_autov3,
)
autov3 = calculate_autov3(file_path)
if autov3:
local_cache[autov3.lower()] = item
except Exception:
pass
break
except Exception:
pass
parser = self._analysis_service._recipe_parser_factory.create_parser(
parsed_input
)
if not parser:
raise RecipeValidationError("Unable to parse image metadata")
from ...recipes.parsers.civitai_image import CivitaiApiMetadataParser
if isinstance(parser, CivitaiApiMetadataParser):
parsed = await parser.parse_metadata(
parsed_input,
recipe_scanner=recipe_scanner,
local_cache=local_cache,
)
else:
parsed = await parser.parse_metadata(
parsed_input, recipe_scanner=recipe_scanner
)
loras = list(parsed.get("loras") or [])
checkpoint = parsed.get("model")
is_lora_type = model_type.startswith("lora")
is_ckpt_type = model_type.startswith("checkpoint")
# Extract parent model metadata from local_cache (used below to
# reconcile isDeleted entries and enrich auto-populated ones).
parent_civitai_id: int | None = None
parent_model_id: int | None = None
parent_version_name: str | None = None
parent_model_name: str | None = None
# Prefer sha256 key; fall back to any cached entry.
parent_item = local_cache.get(model_hash.lower()) if model_hash else None
if parent_item is None and local_cache:
parent_item = next(iter(local_cache.values()))
if parent_item:
civ = parent_item.get("civitai") or {}
if isinstance(civ, dict):
parent_civitai_id = civ.get("id")
parent_model_id = civ.get("modelId")
parent_version_name = civ.get("name")
parent_model_name = parent_item.get("model_name")
# Reconcile isDeleted entries against the parent model.
# When the CivitAI hash lookup fails (known issue — hashes not
# yet computed), the parser marks the entry isDeleted even though
# the model exists locally.
if is_lora_type:
for lora in loras:
if lora.get("isDeleted") and lora.get("file_name") == model_name:
lora["isDeleted"] = False
lora["existsLocally"] = True
lora["hash"] = model_hash
if parent_civitai_id is not None:
lora["id"] = parent_civitai_id
if parent_model_id is not None:
lora["modelId"] = parent_model_id
if parent_version_name is not None:
lora["version"] = parent_version_name
if parent_model_name is not None:
lora["name"] = parent_model_name
elif is_ckpt_type and checkpoint and checkpoint.get("isDeleted"):
if checkpoint.get("file_name") == model_name:
checkpoint["isDeleted"] = False
checkpoint["existsLocally"] = True
checkpoint["hash"] = model_hash
if parent_civitai_id is not None:
checkpoint["id"] = parent_civitai_id
if parent_model_id is not None:
checkpoint["modelId"] = parent_model_id
if parent_version_name is not None:
checkpoint["version"] = parent_version_name
# Auto-populate parent model only when the image metadata didn't
# contain any resources of that type.
if is_lora_type and not loras:
lora_entry = {
"name": model_name,
"type": "lora",
"weight": 1.0,
"hash": model_hash,
"existsLocally": True,
"localPath": None,
"file_name": model_name,
"thumbnailUrl": "/loras_static/images/no-preview.png",
"baseModel": parsed.get("base_model", ""),
"size": 0,
"downloadUrl": "",
"isDeleted": False,
}
if parent_civitai_id is not None:
lora_entry["id"] = parent_civitai_id
if parent_model_id is not None:
lora_entry["modelId"] = parent_model_id
if parent_version_name is not None:
lora_entry["version"] = parent_version_name
if parent_model_name is not None:
lora_entry["name"] = parent_model_name
loras.insert(0, lora_entry)
elif is_ckpt_type and not checkpoint:
checkpoint = {
"name": model_name,
"type": "checkpoint",
"hash": model_hash,
"file_name": model_name,
"existsLocally": True,
"baseModel": parsed.get("base_model", ""),
"isDeleted": False,
}
if parent_civitai_id is not None:
checkpoint["id"] = parent_civitai_id
if parent_model_id is not None:
checkpoint["modelId"] = parent_model_id
if parent_version_name is not None:
checkpoint["version"] = parent_version_name
if parent_model_name is not None:
checkpoint["name"] = parent_model_name
image_url = image_data.get("url") or ""
image_id = extract_civitai_image_id_from_cdn_url(image_url)
settings_mgr = get_settings_manager()
civitai_host = settings_mgr.get("civitai_host") if settings_mgr else None
page_url = build_civitai_image_page_url(image_id, host=civitai_host) or image_url
recipe_metadata: dict[str, Any] = {
"base_model": parsed.get("base_model") or "",
"loras": loras,
"gen_params": parsed.get("gen_params") or {},
"source_path": page_url,
}
nsfw_level = image_data.get("nsfwLevel")
if isinstance(nsfw_level, int):
recipe_metadata["preview_nsfw_level"] = nsfw_level
if checkpoint:
recipe_metadata["checkpoint"] = checkpoint
image_bytes: bytes | None = None
extension: str | None = None
local_image_path = data.get("local_image_path")
if local_image_path and os.path.exists(local_image_path):
with open(local_image_path, "rb") as f:
image_bytes = f.read()
ext = os.path.splitext(local_image_path)[1].lower()
if ext in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
extension = ext
elif image_data.get("url"):
try:
downloader = await self._downloader_factory()
url = image_data["url"]
tmp = tempfile.NamedTemporaryFile(delete=False)
tmp.close()
success, result = await downloader.download_file(
url, tmp.name, use_auth=False
)
if success:
with open(tmp.name, "rb") as f:
image_bytes = f.read()
url_path = url.split("?")[0].split("#")[0]
ext = os.path.splitext(url_path)[1].lower()
if ext:
extension = ext
if os.path.exists(tmp.name):
os.unlink(tmp.name)
except Exception as exc:
self._logger.warning(
"Failed to download image for recipe: %s", exc
)
prompt = (
(parsed.get("gen_params") or {}).get("prompt") or ""
)
if prompt:
name = " ".join(str(prompt).split()[:10])
else:
name = f"Recipe from {model_name}"
save_result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner,
image_bytes=image_bytes,
image_base64=None,
name=name,
tags=[],
metadata=recipe_metadata,
extension=extension,
)
return web.json_response(save_result.payload, status=save_result.status)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
self._logger.error(
"Error creating recipe from example: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
class RecipeAnalysisHandler:
"""Analyze images to extract recipe metadata."""

View File

@@ -101,6 +101,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/download-model", "download_model"),
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
RouteDefinition("GET", "/api/lm/skip-download", "skip_download_get"),
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
RouteDefinition(

View File

@@ -75,6 +75,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"GET", "/api/lm/recipes/check-image-exists", "check_image_exists"
),
RouteDefinition("GET", "/api/lm/recipes/import-from-url", "import_from_url"),
RouteDefinition(
"POST", "/api/lm/recipes/create-from-example", "create_from_example"
),
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -2404,6 +2404,89 @@ class DownloadManager:
self._download_tasks.pop(download_id, None)
await self._aria2_state_store.remove(download_id)
async def skip_download(self, download_id: str) -> Dict:
"""Skip a download while preserving all partial files on disk.
Removes all in-memory tracking (asyncio task, semaphore, active/pause
state) but keeps partial files (.part / .aria2) on disk so that a
subsequent download-model-get request for the same save path can
auto-resume from the preserved partial download.
Args:
download_id: The unique identifier of the download task
Returns:
Dict: Status of the skip operation
"""
await self._restore_persisted_downloads()
if download_id not in self._download_tasks and download_id not in self._active_downloads:
return {"success": False, "error": "Download task not found"}
download_info = self._active_downloads.get(download_id)
task = self._download_tasks.get(download_id)
active_statuses = {"queued", "waiting", "downloading", "paused", "cancelling"}
if task is None and (
not isinstance(download_info, dict)
or download_info.get("status") not in active_statuses
):
return {"success": False, "error": "Download task not found"}
backend = (
self._active_downloads.get(download_id, {}).get("transfer_backend")
or "python"
)
try:
# For aria2: pause the transfer rather than force-removing it, so
# the .aria2 control file stays on disk for future resume
if backend == "aria2":
try:
aria2_downloader = await get_aria2_downloader()
pause_result = await aria2_downloader.pause_download(download_id)
if not pause_result.get("success"):
logger.warning(
"Failed to pause aria2 transfer for %s during skip: %s",
download_id,
pause_result.get("error"),
)
except Exception as exc:
logger.warning(
"Failed to pause aria2 transfer for %s during skip: %s",
download_id,
exc,
)
# Cancel the asyncio task so the semaphore slot is released
if task is not None:
task.cancel()
# Resume pause event so the task can exit cleanly
pause_control = self._pause_events.get(download_id)
if pause_control is not None:
pause_control.resume()
# Wait briefly for task to acknowledge cancellation
if task is not None:
try:
await asyncio.wait_for(asyncio.shield(task), timeout=2.0)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
logger.info(f"Download skipped for task {download_id} (partial files preserved)")
return {"success": True, "message": "Download skipped successfully"}
except Exception as e:
logger.error(f"Error skipping download: {e}", exc_info=True)
return {"success": False, "error": str(e)}
finally:
# Clean up local in-memory tracking only - NO file deletion
self._pause_events.pop(download_id, None)
self._download_tasks.pop(download_id, None)
if download_id in self._active_downloads:
del self._active_downloads[download_id]
# Preserve aria2 state store entry so the partial download
# info survives restarts and can be resumed later
async def pause_download(self, download_id: str) -> Dict:
"""Pause an active download without losing progress."""

View File

@@ -96,6 +96,21 @@ class DownloadedVersionHistoryService:
def get_database_path(self) -> str:
return self._db_path
def close(self) -> None:
"""Close the persistent SQLite connection, if open.
This is called before plugin update operations to release the
database file lock on Windows, allowing ``shutil.rmtree()`` to
succeed when the cache resides inside the plugin directory.
"""
if self._conn is not None:
try:
self._conn.close()
except Exception:
pass
finally:
self._conn = None
def _get_active_library_name(self) -> str | None:
try:
value = self._settings.get_active_library_name()

View File

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

View File

@@ -689,6 +689,7 @@ class ModelUpdateService:
*,
force_refresh: bool = False,
target_model_ids: Optional[Sequence[int]] = None,
folder_path: Optional[str] = None,
) -> Dict[int, ModelUpdateRecord]:
"""Refresh update information for every model present in the cache."""
scanner.reset_cancellation()
@@ -703,6 +704,7 @@ class ModelUpdateService:
local_versions = await self._collect_local_versions(
scanner,
target_model_ids=target_filter,
folder_path=folder_path,
)
total_models = len(local_versions)
if total_models == 0:
@@ -1276,6 +1278,7 @@ class ModelUpdateService:
scanner,
*,
target_model_ids: Optional[Sequence[int]] = None,
folder_path: Optional[str] = None,
) -> Dict[int, List[int]]:
cache = await scanner.get_cached_data()
mapping: Dict[int, set[int]] = {}
@@ -1288,7 +1291,19 @@ class ModelUpdateService:
if not target_set:
return {}
normalized_folder = None
if folder_path is not None:
normalized_folder = folder_path.replace("\\", "/").strip("/")
for item in cache.raw_data:
# Apply folder filter first (cheapest check)
if normalized_folder is not None:
if not isinstance(item, dict):
continue
item_folder = (item.get("folder") or "").replace("\\", "/").strip("/")
if item_folder != normalized_folder and not item_folder.startswith(normalized_folder + "/"):
continue
civitai = item.get("civitai") if isinstance(item, dict) else None
if not isinstance(civitai, dict):
continue

View File

@@ -115,6 +115,10 @@ class RecipePersistenceService:
if metadata.get("source_path"):
recipe_data["source_path"] = metadata.get("source_path")
nsfw_level = metadata.get("preview_nsfw_level")
if nsfw_level is not None and isinstance(nsfw_level, int):
recipe_data["preview_nsfw_level"] = nsfw_level
json_filename = f"{recipe_id}.recipe.json"
json_path = os.path.join(recipes_dir, json_filename)
json_path = os.path.normpath(json_path)

View File

@@ -66,6 +66,46 @@ def build_civitai_model_page_url(
return None
_RE_CDN_IMAGE_ID = re.compile(r"/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)")
def extract_civitai_image_id_from_cdn_url(url: str | None) -> str | None:
"""Extract the numeric image ID from a Cloudflare CDN image URL.
CivitAI image CDN URLs follow the pattern::
https://image.civitai.com/{cf_uuid}/{params}/{image_id}.{ext}
The image database ID is always the last path segment (minus extension)
because ``getEdgeUrl(…, name=id.toString())`` embeds it explicitly
in the model-versions REST API response.
"""
if not url:
return None
match = _RE_CDN_IMAGE_ID.search(url)
return match.group(1) if match else None
def build_civitai_image_page_url(
image_id: str | int | None,
*,
host: str | None = None,
) -> str | None:
"""Build a Civitai image page URL.
Returns something like ``https://civitai.com/images/12345``.
The host is resolved through :func:`normalize_civitai_page_host` and
therefore respects the user's ``civitai_host`` setting.
"""
if not image_id:
return None
normalized_host = normalize_civitai_page_host(host)
normalized_id = str(image_id).strip()
if not normalized_id:
return None
return urlunparse(("https", normalized_host, f"/images/{normalized_id}", "", "", ""))
def _parse_supported_civitai_page_url(url: str | None):
if not url:
return None
@@ -328,8 +368,10 @@ def rewrite_preview_url(
__all__ = [
"build_civitai_image_page_url",
"build_license_flags",
"extract_civitai_image_id",
"extract_civitai_image_id_from_cdn_url",
"extract_civitai_page_host",
"extract_civitai_model_url_parts",
"is_supported_civitai_page_host",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,7 +55,7 @@
padding: 4px 8px;
margin-left: 8px;
border-radius: var(--border-radius-xs);
transition: all 0.2s;
transition: var(--transition-base);
flex-shrink: 0;
display: flex;
align-items: center;
@@ -99,7 +99,7 @@
font-size: 0.9em;
}
/* 删除不再需要的按钮样式 */
/* Remove obsolete button styles */
.editor-actions {
display: none;
}
@@ -144,7 +144,7 @@
}
.recipe-tag-compact {
background: rgba(0, 0, 0, 0.03);
background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs);
padding: 2px 8px;
@@ -154,7 +154,7 @@
}
[data-theme="dark"] .recipe-tag-compact {
background: rgba(255, 255, 255, 0.03);
background: var(--surface-subtle);
border: 1px solid var(--lora-border);
}
@@ -176,14 +176,14 @@
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
box-shadow: var(--shadow-dropdown);
padding: 10px 14px;
max-width: 400px;
z-index: 10;
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: all 0.2s ease;
transition: var(--transition-base);
pointer-events: none;
}
@@ -203,7 +203,7 @@
}
.tooltip-tag {
background: rgba(0, 0, 0, 0.03);
background: var(--surface-hover);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs);
padding: 3px 8px;
@@ -212,7 +212,7 @@
}
[data-theme="dark"] .tooltip-tag {
background: rgba(255, 255, 255, 0.03);
background: var(--surface-hover);
border: 1px solid var(--lora-border);
}
@@ -251,19 +251,19 @@
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.03);
background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-weight: 500;
font-size: 0.9em;
transition: all 0.2s;
transition: var(--transition-base);
white-space: nowrap;
}
[data-theme="dark"] .recipe-source-url-btn {
background: rgba(255, 255, 255, 0.03);
background: var(--surface-subtle);
border: 1px solid var(--lora-border);
}
@@ -428,7 +428,7 @@
font-size: 0.85em;
cursor: pointer;
border: none;
transition: all 0.2s;
transition: var(--transition-base);
}
.source-url-cancel-btn {
@@ -548,7 +548,7 @@
cursor: pointer;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
transition: all 0.2s;
transition: var(--transition-base);
}
.copy-btn:hover,
@@ -705,7 +705,7 @@
cursor: pointer;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
transition: all 0.2s;
transition: var(--transition-base);
display: flex;
align-items: center;
justify-content: center;
@@ -725,7 +725,7 @@
cursor: pointer;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
transition: all 0.2s;
transition: var(--transition-base);
display: flex;
align-items: center;
justify-content: center;
@@ -797,7 +797,7 @@
.recipe-lora-item:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-header);
border-color: var(--lora-accent);
}
@@ -995,7 +995,7 @@
padding: 8px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-header);
z-index: var(--z-overlay);
width: max-content;
max-width: 200px;
@@ -1049,7 +1049,7 @@
background: rgba(0, 0, 0, 0.1);
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
font-family: var(--font-mono);
font-size: 0.9em;
}
@@ -1086,7 +1086,7 @@
font-size: 0.85em;
cursor: pointer;
border: none;
transition: all 0.2s;
transition: var(--transition-base);
}
.reconnect-cancel-btn {
@@ -1114,9 +1114,9 @@
color: #777;
}
/* 标题输入框特定的样式 */
/* Title input specific styles */
.title-input {
font-size: 1.2em !important; /* 调整为更合适的大小 */
font-size: 1.2em;
line-height: 1.2;
font-weight: 500;
}
@@ -1251,7 +1251,7 @@
padding: 8px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-header);
z-index: var(--z-overlay);
width: max-content;
max-width: 200px;

View File

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

View File

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

View File

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

View File

@@ -8,10 +8,10 @@
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: var(--transition-slow);
flex-shrink: 0;
z-index: var(--z-overlay);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-header);
display: flex;
flex-direction: column;
backdrop-filter: blur(8px);
@@ -83,7 +83,7 @@
flex-shrink: 0;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.sidebar-header:hover {
@@ -120,7 +120,7 @@
padding: 4px;
border-radius: 4px;
opacity: 0.6;
transition: all 0.2s ease;
transition: var(--transition-base);
width: 24px;
height: 24px;
display: flex;
@@ -174,7 +174,7 @@
align-items: center;
padding: 8px 16px;
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
font-size: 0.85em;
border-left: 3px solid transparent;
color: var(--text-color);
@@ -298,7 +298,7 @@
padding: 4px 8px;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
color: var(--text-muted);
position: relative;
}
@@ -331,7 +331,7 @@
margin-left: 6px;
color: inherit;
opacity: 0.6;
transition: all 0.2s ease;
transition: var(--transition-base);
pointer-events: none;
font-size: 0.9em;
}
@@ -364,7 +364,7 @@
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: 0 3px 8px rgba(0,0,0,0.15);
box-shadow: var(--shadow-lg);
z-index: calc(var(--z-overlay) + 20);
overflow-y: auto;
max-height: 450px;
@@ -382,7 +382,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.breadcrumb-dropdown-item:hover {
@@ -406,7 +406,7 @@
align-items: center;
padding: 8px 16px;
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
font-size: 0.85em;
border-left: 3px solid transparent;
color: var(--text-color);
@@ -614,7 +614,7 @@
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08);
opacity: 0;
transform: translateY(10px);
transition: all 0.2s ease;
transition: var(--transition-base);
pointer-events: none;
z-index: 10;
}
@@ -649,7 +649,7 @@
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
box-shadow: var(--shadow-lg);
z-index: 20;
animation: slideUp 0.2s ease;
}
@@ -685,7 +685,7 @@
color: var(--text-color);
font-size: 0.85em;
outline: none;
transition: all 0.2s ease;
transition: var(--transition-base);
}
.sidebar-create-folder-input:focus {
@@ -702,24 +702,30 @@
border: none;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: all 0.2s ease;
transition: var(--transition-base);
background: transparent;
color: var(--text-muted);
}
.sidebar-create-folder-btn:hover {
.sidebar-create-folder-btn:hover,
.sidebar-create-folder-btn:focus-visible {
background: var(--lora-surface);
color: var(--text-color);
outline: none;
}
.sidebar-create-folder-confirm:hover {
.sidebar-create-folder-confirm:hover,
.sidebar-create-folder-confirm:focus-visible {
background: oklch(from var(--success-color) l c h / 0.15);
color: var(--success-color);
outline: none;
}
.sidebar-create-folder-cancel:hover {
.sidebar-create-folder-cancel:hover,
.sidebar-create-folder-cancel:focus-visible {
background: oklch(from var(--error-color) l c h / 0.15);
color: var(--error-color);
outline: none;
}
.sidebar-create-folder-hint {
@@ -745,3 +751,8 @@
.sidebar-tree-container {
position: relative;
}
/* Folder context menu - positioned relative to sidebar */
#sidebarFolderContextMenu {
z-index: var(--z-modal, 1002);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,7 +54,7 @@
text-align: center;
}
/* 使用已有的loading-spinner样式 */
/* Reuse existing loading-spinner styles */
.initialization-notice .loading-spinner {
margin-bottom: var(--space-2);
}

View File

@@ -0,0 +1,142 @@
# Lora-Manager UI Token Migration Guide
## Overview
The design token system has been created in `static/css/tokens/`. `base.css` now imports the tokens and provides backward-compatible aliases for existing component CSS.
## Token Files
| File | Purpose |
|------|---------|
| `tokens/colors.css` | OKLch color primitives + semantic light/dark tokens |
| `tokens/typography.css` | Font stacks, type scale, weights, line heights |
| `tokens/spacing.css` | 4px-base grid with legacy aliases |
| `tokens/effects.css` | Border radius, shadows, transitions |
| `tokens/breakpoints.css` | Named breakpoint variables |
| `tokens/z-index.css` | Stacking context scale |
| `tokens/index.css` | Aggregator that imports all token files |
## Backward Compatibility
Old variable names in component CSS still work via aliases in `base.css`:
| Old Name | Maps To |
|----------|---------|
| `--bg-color` | `--bg-base` |
| `--text-color` | `--text-primary` |
| `--text-muted` | `--text-secondary` |
| `--card-bg` | `--surface-base` |
| `--border-color` | `--border-base` |
| `--lora-accent` | `--color-accent` |
| `--lora-surface` | `--bg-elevated` |
| `--lora-border` | `--border-subtle` |
| `--space-1` (8px) | `--space-1-legacy` |
| `--border-radius-base` | `--radius-lg` |
## Phase 2: Component Audit Checklist
Below are the hardcoded values found across component CSS that should be replaced with tokens.
### Critical Fixes (P0)
- [ ] **card.css line 441**: `.base-model { background: #f0f0f0; }` → use `--bg-hover` or new `--surface-variant`
- [ ] **card.css line 369**: `.favorite-active { color: #ffc107 !important; }` → use `--favorite-color` (already defined in tokens)
- [ ] **layout.css line 134**: `.control-group button.favorite-filter i { color: #ffc107; }` → use `--favorite-color`
- [ ] **header.css lines 233-250**: Hardcoded dark theme colors (`#3a3a3a`, `#888888`, `#555555`) → use `--bg-disabled`, `--text-secondary`, `--border-base`
### Spacing Normalization (P1)
Replace hard pixel values with token equivalents:
- [ ] `padding: 4px 10px``padding: var(--space-1) var(--space-3)`
- [ ] `gap: 6px``gap: var(--space-1-legacy)` or `gap: var(--space-2)`
- [ ] `gap: 8px``gap: var(--space-2)`
- [ ] `gap: 12px``gap: var(--space-3)`
- [ ] `padding: 15px``padding: var(--space-4)`
- [ ] `padding: 16px``padding: var(--space-4)`
- [ ] `margin-top: 2px``margin-top: var(--space-0-5)`
- [ ] `padding: 2px 6px``padding: var(--space-0-5) var(--space-2)`
- [ ] `border-radius: 50%``border-radius: var(--radius-full)`
### Shadow Standardization (P1)
Replace hardcoded shadows with token equivalents:
- [ ] `box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05)``box-shadow: var(--shadow-xs)`
- [ ] `box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05)``box-shadow: var(--shadow-sm)`
- [ ] `box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)``box-shadow: var(--shadow-md)`
- [ ] `box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08)``box-shadow: var(--shadow-lg)`
- [ ] `box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15)``box-shadow: var(--shadow-xl)`
- [ ] `box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08)` → combine or add new token
### Typography Normalization (P1)
Replace scattered font sizes with type scale:
- [ ] `font-size: 0.8em``font-size: var(--text-xs)`
- [ ] `font-size: 0.85em``font-size: var(--text-sm)`
- [ ] `font-size: 0.9em``font-size: var(--text-sm)`
- [ ] `font-size: 0.95em``font-size: var(--text-md)`
- [ ] `font-size: 1.1em``font-size: var(--text-lg)`
- [ ] `font-size: 11px``font-size: var(--text-xs)`
### Breakpoint Normalization (P2)
Replace magic numbers with named breakpoints:
- [ ] `@media (min-width: 2150px)``@media (min-width: var(--bp-ultrawide))`
- [ ] `@media (min-width: 3000px)``@media (min-width: var(--bp-4k))`
- [ ] `@media (max-width: 768px)``@media (max-width: var(--bp-mobile))`
- [ ] `@media (max-width: 1200px)``@media (max-width: var(--bp-desktop))`
### Z-Index Cleanup (P2)
Replace magic z-index values with tokens:
- [ ] `z-index: 2` / `z-index: 3` / `z-index: 4` in card.css → use `--z-base` + calc
- [ ] `z-index: 200` in header.css (hamburger dropdown) → use `--z-dropdown`
### Remaining Hardcoded Colors (P2)
- [ ] `rgba(0, 184, 122, 0.05)` and `#00B87A` in import-modal.css → use `--color-success`
- [ ] `rgba(255, 255, 255, 0.12)` in card.css (base-model-label background) → use token
- [ ] `rgba(255, 255, 255, 0.25)` in card.css (separator) → use `--border-inverse`
- [ ] `rgba(0, 0, 0, 0.5)` and `rgba(0, 0, 0, 0.7)` in card.css (toggle blur btn) → use `--bg-overlay` variants
- [ ] `rgba(46, 204, 113, 0.3)` and `rgba(231, 76, 60, 0.3)` in card.css → use success/error tokens
## New Tokens Added
The following tokens were added beyond the existing system:
| Token | Value | Use Case |
|-------|-------|----------|
| `--color-accent-hover` | oklch(58% 0.28 256) | Hover states for accent buttons |
| `--color-accent-subtle` | accent @ 12% opacity | Subtle accent backgrounds |
| `--color-accent-border` | accent @ 25% opacity | Accent borders |
| `--color-accent-transparent` | accent @ 60% opacity | Glow effects, pulse animations |
| `--bg-hover` | oklch(95% 0.02 256) / dark: oklch(35% 0.02 256) | Hover backgrounds |
| `--bg-disabled` | #f5f5f5 / dark: #3a3a3a | Disabled input backgrounds |
| `--bg-overlay` | oklch(0% 0 0 / 0.75) | Modal overlays, gradients |
| `--surface-hover` | oklch(95% 0.02 256) / dark: oklch(35% 0.02 256) | Card/panel hover |
| `--favorite-color` | #d4a017 | Accessible gold for favorites |
| `--shadow-focus` | 0 0 0 1px accent | Focus ring shadow |
| `--shadow-glow` | 0 2px 6px info-glow | Badge glow effects |
| `--transition-bounce` | 200ms cubic-bezier | Playful hover transitions |
## Migration Order Recommendation
1. **Start with colors**: Replace `#ffc107` and `#f0f0f0` (highest visual impact)
2. **Then spacing**: Unify padding/gap values (biggest consistency win)
3. **Then shadows**: Replace rgba shadows with tokens
4. **Then typography**: Standardize font sizes
5. **Finally breakpoints + z-index**: Lower priority but good for maintainability
## Testing Checklist
After each component file is migrated:
- [ ] Light theme renders correctly
- [ ] Dark theme renders correctly
- [ ] No visual regressions in card grid, header, modals
- [ ] Focus states still visible
- [ ] Hover transitions still work (unless prefers-reduced-motion)

View File

@@ -0,0 +1,8 @@
:root {
--bp-mobile: 768px;
--bp-tablet: 1024px;
--bp-desktop: 1400px;
--bp-wide: 1920px;
--bp-ultrawide: 2150px;
--bp-4k: 3000px;
}

View File

@@ -0,0 +1,117 @@
:root {
--color-accent-l: 68%;
--color-accent-c: 0.28;
--color-accent-h: 256;
--color-warning-l: 75%;
--color-warning-c: 0.25;
--color-warning-h: 80;
--color-success-l: 70%;
--color-success-c: 0.2;
--color-success-h: 140;
--color-error-l: 75%;
--color-error-c: 0.32;
--color-error-h: 29;
--color-info-l: 72%;
--color-info-c: 0.2;
--color-info-h: 220;
--color-neutral-h: 250;
}
:root {
--color-accent: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
--color-accent-hover: oklch(58% var(--color-accent-c) var(--color-accent-h));
--color-accent-subtle: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.12);
--color-accent-border: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.25);
--color-accent-transparent: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.6);
--color-warning: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h));
--color-warning-bg: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h) / 0.15);
--color-warning-border: oklch(var(--color-warning-l) var(--color-warning-c) var(--color-warning-h) / 0.3);
--color-success: oklch(var(--color-success-l) var(--color-success-c) var(--color-success-h));
--color-success-bg: oklch(var(--color-success-l) var(--color-success-c) var(--color-success-h) / 0.2);
--color-success-border: oklch(var(--color-success-l) var(--color-success-c) var(--color-success-h) / 0.3);
--color-error: oklch(var(--color-error-l) var(--color-error-c) var(--color-error-h));
--color-error-bg: color-mix(in oklch, var(--color-error) 20%, transparent);
--color-error-border: color-mix(in oklch, var(--color-error) 50%, transparent);
--color-info: oklch(var(--color-info-l) var(--color-info-c) var(--color-info-h));
--color-info-bg: oklch(72% 0.2 220);
--color-info-text: oklch(28% 0.03 220);
--color-info-glow: oklch(72% 0.2 220 / 0.28);
--color-skip-refresh-bg: oklch(82% 0.12 45);
--color-skip-refresh-text: oklch(35% 0.02 45);
--color-skip-refresh-glow: oklch(82% 0.12 45 / 0.15);
}
:root {
--bg-base: #ffffff;
--bg-elevated: oklch(97% 0 0 / 0.95);
--bg-overlay: oklch(0% 0 0 / 0.75);
--bg-hover: oklch(95% 0.02 256);
--bg-disabled: #f5f5f5;
--text-primary: #333333;
--text-secondary: #6c757d;
--text-inverse: #ffffff;
--text-muted-on-dark: rgba(255, 255, 255, 0.8);
--surface-base: #ffffff;
--surface-elevated: oklch(97% 0 0 / 0.95);
--surface-hover: oklch(95% 0.02 256);
--surface-subtle: oklch(0% 0 0 / 0.03);
--border-base: #e0e0e0;
--border-subtle: oklch(72% 0.03 256 / 0.45);
--border-inverse: rgba(255, 255, 255, 0.25);
--status-success-text: oklch(75% 0.12 230);
--status-success-bg: oklch(55% 0.15 240 / 0.25);
--status-success-border: oklch(60% 0.18 250 / 0.3);
--status-info-text: oklch(78% 0.10 185);
--status-info-bg: oklch(50% 0.10 190 / 0.25);
--status-info-border: oklch(55% 0.12 195 / 0.3);
--favorite-color: #d4a017;
--favorite-glow: oklch(65% 0.15 85 / 0.5);
}
[data-theme="dark"] {
--bg-base: #1a1a1a;
--bg-elevated: oklch(25% 0.02 256 / 0.98);
--bg-overlay: oklch(0% 0 0 / 0.75);
--bg-hover: oklch(35% 0.02 256);
--bg-disabled: #3a3a3a;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-inverse: #1a1a1a;
--text-muted-on-dark: rgba(255, 255, 255, 0.8);
--surface-base: #2d2d2d;
--surface-elevated: oklch(25% 0.02 256 / 0.98);
--surface-hover: oklch(35% 0.02 256);
--surface-subtle: oklch(100% 0 0 / 0.03);
--border-base: #404040;
--border-subtle: oklch(90% 0.02 256 / 0.15);
--border-inverse: rgba(255, 255, 255, 0.25);
--status-success-text: oklch(75% 0.12 230);
--status-success-bg: oklch(55% 0.15 240 / 0.25);
--status-success-border: oklch(60% 0.18 250 / 0.3);
--status-info-text: oklch(78% 0.10 185);
--status-info-bg: oklch(50% 0.10 190 / 0.25);
--status-info-border: oklch(55% 0.12 195 / 0.3);
--color-info-bg: oklch(62% 0.18 220);
--color-info-text: oklch(98% 0.02 240);
--color-info-glow: oklch(62% 0.18 220 / 0.4);
--color-error-bg: color-mix(in oklch, var(--color-error) 15%, transparent);
--color-error-border: color-mix(in oklch, var(--color-error) 40%, transparent);
--favorite-color: #ffc107;
}

View File

@@ -0,0 +1,57 @@
:root {
--radius-none: 0px;
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
--shadow-md: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 3px 5px rgba(0, 0, 0, 0.08);
--shadow-xl: 0 4px 16px rgba(0, 0, 0, 0.15);
--shadow-2xl: 0 8px 32px rgba(0, 0, 0, 0.12);
--shadow-focus: 0 0 0 1px var(--color-accent);
--shadow-glow: 0 2px 6px var(--color-info-glow);
--shadow-card: var(--shadow-sm);
--shadow-dropdown: var(--shadow-md);
--shadow-modal: var(--shadow-lg);
--shadow-toast: var(--shadow-xl);
--shadow-header: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-dark-lg: 0 4px 24px rgba(0, 0, 0, 0.4);
--shadow-side: 2px 0 8px rgba(0, 0, 0, 0.1);
--shadow-elevated: 0 4px 12px rgba(0, 0, 0, 0.15);
--shadow-dialog: 0 10px 24px rgba(0, 0, 0, 0.25);
--shadow-inset-top: 0 -2px 8px rgba(0, 0, 0, 0.1);
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
--transition-bounce: 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
--border-width-thin: 1px;
--border-width-thick: 2px;
}
[data-theme="dark"] {
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.25);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25);
--shadow-md: 0 2px 4px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 3px 5px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 4px 16px rgba(0, 0, 0, 0.45);
--shadow-2xl: 0 8px 32px rgba(0, 0, 0, 0.35);
--shadow-card: var(--shadow-sm);
--shadow-dropdown: var(--shadow-md);
--shadow-modal: var(--shadow-lg);
--shadow-toast: var(--shadow-xl);
--shadow-header: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-dark-lg: 0 4px 24px rgba(0, 0, 0, 0.6);
--shadow-side: 2px 0 8px rgba(0, 0, 0, 0.3);
--shadow-elevated: 0 4px 12px rgba(0, 0, 0, 0.35);
--shadow-dialog: 0 10px 24px rgba(0, 0, 0, 0.45);
--shadow-inset-top: 0 -2px 8px rgba(0, 0, 0, 0.3);
}

View File

@@ -0,0 +1,6 @@
@import 'colors.css';
@import 'typography.css';
@import 'spacing.css';
@import 'effects.css';
@import 'breakpoints.css';
@import 'z-index.css';

View File

@@ -0,0 +1,19 @@
:root {
--space-0-5: 2px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-20: 80px;
--space-1-legacy: calc(8px * 1);
--space-2-legacy: calc(8px * 2);
--space-3-legacy: calc(8px * 3);
--space-4-legacy: calc(8px * 4);
}

View File

@@ -0,0 +1,23 @@
:root {
--font-display: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', system-ui, sans-serif;
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-md: 0.95rem;
--text-lg: 1.1rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 2rem;
--leading-tight: 1.2;
--leading-normal: 1.4;
--leading-relaxed: 1.5;
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
}

View File

@@ -0,0 +1,11 @@
:root {
--z-base: 10;
--z-sticky: 50;
--z-header: 100;
--z-dropdown: 200;
--z-modal-backdrop: 500;
--z-modal: 1000;
--z-overlay: 2000;
--z-toast: 3000;
--z-tooltip: 4000;
}

View File

@@ -766,6 +766,49 @@ export class BaseModelApiClient {
}
}
async refreshUpdatesForFolder(folderPath, { force = false } = {}) {
if (!folderPath) {
throw new Error('No folder path provided');
}
try {
state.loadingManager.show('Checking for updates...', 0);
state.loadingManager.showCancelButton(() => this.cancelTask());
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
folder_path: folderPath,
force
})
});
let payload = {};
try {
payload = await response.json();
} catch (error) {
console.warn('Unable to parse refresh updates response as JSON', error);
}
if (!response.ok || payload?.success !== true) {
if (payload?.status === 'cancelled') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
const message = payload?.error || response.statusText || 'Failed to refresh updates';
throw new Error(message);
}
return payload;
} catch (error) {
console.error('Error refreshing updates for folder:', error);
throw error;
} finally {
state.loadingManager.hide();
}
}
async fetchCivitaiVersions(modelId, source = null) {
try {
let requestUrl = `${this.apiConfig.endpoints.civitaiVersions}/${modelId}`;
@@ -909,7 +952,7 @@ export class BaseModelApiClient {
}
}
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null) {
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null, fileParams = null) {
try {
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
method: 'POST',
@@ -921,7 +964,8 @@ export class BaseModelApiClient {
relative_path: relativePath,
use_default_paths: useDefaultPaths,
download_id: downloadId,
...(source ? { source } : {})
...(source ? { source } : {}),
...(fileParams ? { file_params: fileParams } : {})
})
});

View File

@@ -197,8 +197,8 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
// Reset page counter
pageState.currentPage = 1;
// Fetch the first page
const result = await fetchPageFunction(1, pageState.pageSize || 50);
const pageSize = state.virtualScroller?.pageSize || pageState.pageSize || 100;
const result = await fetchPageFunction(1, pageSize);
// Update the virtual scroller
state.virtualScroller.refreshWithData(
@@ -251,8 +251,8 @@ export async function loadMoreWithVirtualScroll(options = {}) {
pageState.currentPage = 1;
}
// Fetch the first page of data
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
const pageSize = state.virtualScroller?.pageSize || pageState.pageSize || 100;
const result = await fetchPageFunction(pageState.currentPage, pageSize);
// Update virtual scroller with the new data
state.virtualScroller.refreshWithData(
@@ -294,47 +294,41 @@ export async function resetAndReload(updateFolders = false, options = {}) {
}
/**
* Sync changes - quick refresh without rebuilding cache (similar to models page)
* Refreshes the recipe list by triggering a backend scan, then reloading.
* @param {boolean} fullRebuild - If true, fully rebuild the cache; if false, incremental scan
*/
export async function syncChanges() {
try {
state.loadingManager.showSimpleLoading('Syncing changes...');
// Simply reload the recipes without rebuilding cache
await resetAndReload(false, { preserveScroll: true });
showToast('toast.recipes.syncComplete', {}, 'success');
} catch (error) {
console.error('Error syncing recipes:', error);
showToast('toast.recipes.syncFailed', { message: error.message }, 'error');
} finally {
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
}
return refreshRecipes(false);
}
/**
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
*/
export async function refreshRecipes() {
try {
state.loadingManager.showSimpleLoading('Refreshing recipes...');
export async function refreshRecipes(fullRebuild = true) {
const actionLabel = fullRebuild ? 'Rebuilding recipe cache' : 'Refreshing recipes';
const actionToast = fullRebuild ? 'Full rebuild' : 'Refresh';
// Call the API endpoint to rebuild the recipe cache
const response = await fetch(RECIPE_ENDPOINTS.scan);
try {
state.loadingManager.show(`${actionLabel}...`, 0);
const url = new URL(RECIPE_ENDPOINTS.scan, window.location.origin);
url.searchParams.append('full_rebuild', fullRebuild);
const response = await fetch(url);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to refresh recipe cache');
throw new Error(`Failed to refresh recipe cache: ${response.status} ${response.statusText}`);
}
// After successful cache rebuild, reload the recipes
await resetAndReload(false, { preserveScroll: true });
const data = await response.json();
if (data.status === 'cancelled') {
showToast('toast.api.operationCancelled', {}, 'info');
return;
}
showToast('toast.recipes.refreshComplete', {}, 'success');
await resetAndReload(false);
showToast('toast.api.refreshComplete', { action: actionToast }, 'success');
} catch (error) {
console.error('Error refreshing recipes:', error);
showToast('toast.recipes.refreshFailed', { message: error.message }, 'error');
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: 'recipe' }, 'error');
} finally {
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();

View File

@@ -306,8 +306,14 @@ export class RecipeContextMenu extends BaseContextMenu {
if (result.success) {
if (result.repaired > 0) {
showToast('recipes.contextMenu.repair.success', {}, 'success');
// Refresh the current card or reload
this.resetAndReload();
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
if (detailResponse.ok) {
const updatedRecipe = await detailResponse.json();
const filePath = this.currentCard?.dataset?.filepath;
if (filePath && state.virtualScroller) {
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
}
}
} else {
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
}

View File

@@ -28,6 +28,7 @@ class RecipeCard {
card.dataset.created = this.recipe.created_date;
card.dataset.id = this.recipe.id || '';
card.dataset.folder = this.recipe.folder || '';
card.dataset.favorite = this.recipe.favorite ? 'true' : 'false';
// Get base model with fallback
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
@@ -161,6 +162,7 @@ class RecipeCard {
// Update early to provide instant feedback and avoid race conditions with re-renders
this.recipe.favorite = newFavoriteState;
card.dataset.favorite = newFavoriteState ? 'true' : 'false';
// Function to update icon state
const updateIconUI = (icon, state) => {

View File

@@ -7,6 +7,7 @@ import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
import { escapeHtml, escapeAttribute } from './shared/utils.js';
export class SidebarManager {
@@ -41,6 +42,7 @@ export class SidebarManager {
// Bind methods
this.handleTreeClick = this.handleTreeClick.bind(this);
this.handleTreeContextMenu = this.handleTreeContextMenu.bind(this);
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
@@ -185,6 +187,8 @@ export class SidebarManager {
}
if (folderTree) {
folderTree.removeEventListener('click', this.handleTreeClick);
folderTree.removeEventListener('contextmenu', this.handleTreeContextMenu);
folderTree.removeEventListener('dragover', this.handleFolderDragOver);
}
if (sidebarBreadcrumbNav) {
sidebarBreadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick);
@@ -977,6 +981,7 @@ export class SidebarManager {
const folderTree = document.getElementById('sidebarFolderTree');
if (folderTree) {
folderTree.addEventListener('click', this.handleTreeClick);
folderTree.addEventListener('contextmenu', this.handleTreeContextMenu);
}
// Breadcrumb click handler
@@ -1027,6 +1032,19 @@ export class SidebarManager {
if (displayModeToggleBtn) {
displayModeToggleBtn.addEventListener('click', this.handleDisplayModeToggle);
}
// Sidebar folder context menu click handler
const sidebarFolderMenu = document.getElementById('sidebarFolderContextMenu');
if (sidebarFolderMenu) {
sidebarFolderMenu.addEventListener('click', (e) => {
const item = e.target.closest('.context-menu-item');
if (!item) return;
const action = item.dataset.action;
if (action) {
this.handleFolderContextMenuAction(action);
}
});
}
}
handleDocumentClick(event) {
@@ -1398,6 +1416,82 @@ export class SidebarManager {
}
}
handleTreeContextMenu(event) {
const nodeContent = event.target.closest('.sidebar-tree-node, .sidebar-folder-item');
if (!nodeContent) return;
event.preventDefault();
event.stopPropagation();
const path = nodeContent.dataset.path;
if (path === undefined || path === null || path === '') return;
this._showFolderContextMenu(event.clientX, event.clientY, path);
}
_showFolderContextMenu(x, y, path) {
this._closeFolderContextMenu();
const menu = document.getElementById('sidebarFolderContextMenu');
if (!menu) return;
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.display = 'block';
menu.dataset.folderPath = path;
this._folderContextOpen = true;
// Close on next click outside
this._folderContextCloseHandler = (e) => {
if (!menu.contains(e.target)) {
this._closeFolderContextMenu();
}
};
setTimeout(() => {
document.addEventListener('click', this._folderContextCloseHandler);
}, 0);
}
_closeFolderContextMenu() {
const menu = document.getElementById('sidebarFolderContextMenu');
if (menu) {
menu.style.display = 'none';
delete menu.dataset.folderPath;
}
if (this._folderContextCloseHandler) {
document.removeEventListener('click', this._folderContextCloseHandler);
this._folderContextCloseHandler = null;
}
this._folderContextOpen = false;
}
handleFolderContextMenuAction(action) {
const menu = document.getElementById('sidebarFolderContextMenu');
if (!menu) return;
const path = menu.dataset.folderPath;
this._closeFolderContextMenu();
if (!path) return;
this._performFolderAction(action, path);
}
async _performFolderAction(action, path) {
switch (action) {
case 'check-folder-updates':
try {
await performFolderUpdateCheck(path);
} catch (error) {
console.error('Folder update check failed:', error);
}
break;
default:
console.warn('Unknown folder action:', action);
}
}
handleBreadcrumbClick(event) {
const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item');
const dropdownItem = event.target.closest('.breadcrumb-dropdown-item');

View File

@@ -510,7 +510,12 @@ export async function showModelModal(model, modelType) {
</div>
${typeSpecificContent}
<div class="info-item notes">
<label>${translate('modals.model.metadata.additionalNotes', {}, 'Additional Notes')} <i class="fas fa-info-circle notes-hint" title="${translate('modals.model.metadata.notesHint', {}, 'Press Enter to save, Shift+Enter for new line')}"></i></label>
<div class="notes-header">
<label>${translate('modals.model.metadata.additionalNotes', {}, 'Additional Notes')} <i class="fas fa-info-circle notes-hint" title="${translate('modals.model.metadata.notesHint', {}, 'Press Enter to save, Shift+Enter for new line')}"></i></label>
<button class="notes-toggle-btn" style="display:none" title="${translate('modals.model.notes.showMore', {}, 'Show more')}">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div class="editable-field">
<div class="notes-content" contenteditable="true" spellcheck="false">${modelWithFullData.notes || translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...')}</div>
</div>
@@ -522,7 +527,7 @@ export async function showModelModal(model, modelType) {
</div>
</div>
<div class="showcase-section" data-model-hash="${modelWithFullData.sha256 || ''}" data-filepath="${escapedFilePathAttr}">
<div class="showcase-section" data-model-hash="${modelWithFullData.sha256 || ''}" data-model-name="${escapeAttribute(modelWithFullData.file_name || modelWithFullData.model_name || '')}" data-model-type="${modelType}" data-filepath="${escapedFilePathAttr}">
<div class="showcase-tabs">
${tabsContent}
</div>
@@ -837,12 +842,70 @@ function setupEditableFields(filePath, modelType) {
});
}
// Setup adaptive expand/collapse for notes
setupNotesExpand();
// LoRA specific field setup
if (modelType === 'loras') {
setupLoraSpecificFields(filePath);
}
}
/**
* Adaptive expand/collapse for the Additional Notes section.
* Measures content height synchronously after render (before first paint,
* so no visual flash). If notes fit within ~4 lines, no toggle is shown.
* If they exceed the threshold, the field collapses with a gradient fade
* and a "Show more" button appears.
*/
function setupNotesExpand() {
const notesContainer = document.querySelector('.info-item.notes');
if (!notesContainer) return;
const notesField = notesContainer.querySelector('.editable-field');
const notesContent = notesContainer.querySelector('.notes-content');
const toggleBtn = notesContainer.querySelector('.notes-toggle-btn');
if (!notesField || !notesContent || !toggleBtn) return;
const placeholderText = translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...');
const content = notesContent.textContent || '';
const isEmpty = !content.trim() || content === placeholderText;
if (isEmpty) {
return;
}
// CSS default has no constraints, so scrollHeight reflects full content
const contentHeight = notesContent.scrollHeight;
const collapsedThreshold = 95; // ~4 lines
if (contentHeight <= collapsedThreshold) {
return;
}
// Long content — collapse and show toggle
notesField.classList.add('collapsed');
toggleBtn.style.display = 'inline-flex';
toggleBtn.title = translate('modals.model.notes.showMore', {}, 'Show more');
const toggleIcon = toggleBtn.querySelector('i');
toggleBtn.addEventListener('click', function onClick() {
const isCollapsed = notesField.classList.contains('collapsed');
if (isCollapsed) {
notesField.classList.remove('collapsed');
toggleBtn.title = translate('modals.model.notes.showLess', {}, 'Show less');
toggleIcon.className = 'fas fa-chevron-up';
notesField.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
notesField.classList.add('collapsed');
toggleBtn.title = translate('modals.model.notes.showMore', {}, 'Show more');
toggleIcon.className = 'fas fa-chevron-down';
}
});
}
function setupLoraSpecificFields(filePath) {
const presetSelector = document.getElementById('preset-selector');
const presetValue = document.getElementById('preset-value');

View File

@@ -135,6 +135,39 @@ export function initLazyLoading(container) {
lazyElements.forEach(element => observer.observe(element));
}
/**
* Check which Create As Recipe buttons correspond to already-imported
* images and disable them.
*/
async function checkImportedRecipes(container) {
const recipeButtons = container.querySelectorAll('.create-recipe-btn');
if (!recipeButtons.length) return;
const imageIds = [];
recipeButtons.forEach(btn => {
const id = btn.dataset.imageId;
if (id) imageIds.push(id);
});
if (!imageIds.length) return;
try {
const response = await fetch(`/api/lm/recipes/check-image-exists?image_ids=${imageIds.join(',')}`);
const data = await response.json();
if (!data.success || !data.results) return;
recipeButtons.forEach(btn => {
const id = btn.dataset.imageId;
if (id && data.results[id]?.in_library) {
btn.title = 'Already imported as recipe';
btn.classList.add('disabled');
btn.setAttribute('aria-disabled', 'true');
}
});
} catch (err) {
console.error('Failed to check imported recipes:', err);
}
}
/**
* Get the actual rendered rectangle of a media element with object-fit: contain
* @param {HTMLElement} mediaElement - The img or video element
@@ -471,6 +504,75 @@ export function initMediaControlHandlers(container) {
});
});
// Create As Recipe buttons
const recipeButtons = container.querySelectorAll('.create-recipe-btn');
recipeButtons.forEach(btn => {
btn.addEventListener('click', async function(e) {
e.stopPropagation();
// Ignore clicks when disabled
if (this.classList.contains('disabled')) {
return;
}
const imageMetaRaw = this.dataset.imageMeta;
const imageUrl = this.dataset.imageUrl;
const imageNsfw = this.dataset.imageNsfw;
const localPath = this.dataset.localPath || '';
const showcaseSection = this.closest('.showcase-section');
const modelHash = showcaseSection ? showcaseSection.dataset.modelHash : '';
const modelName = showcaseSection ? showcaseSection.dataset.modelName : '';
const modelType = showcaseSection ? showcaseSection.dataset.modelType : '';
if (!imageMetaRaw || !modelHash) {
showToast('toast.recipes.createMissingData', {}, 'error');
return;
}
// Show loading state
const originalHtml = this.innerHTML;
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
this.disabled = true;
try {
const imageMeta = JSON.parse(decodeURIComponent(imageMetaRaw));
const response = await fetch('/api/lm/recipes/create-from-example', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_data: {
meta: imageMeta,
url: imageUrl,
nsfwLevel: imageNsfw ? parseInt(imageNsfw, 10) : undefined,
},
model_hash: modelHash,
model_name: modelName || modelHash,
model_type: modelType,
local_image_path: localPath,
}),
});
const result = await response.json();
if (result.success && result.recipe_id) {
showToast('toast.recipes.created', { recipeId: result.recipe_id }, 'success');
} else {
showToast('toast.recipes.createFailed', { error: result.error || 'Unknown error' }, 'error');
}
} catch (error) {
console.error('Failed to create recipe:', error);
showToast('toast.recipes.createError', { message: error.message }, 'error');
} finally {
this.innerHTML = originalHtml;
this.disabled = false;
}
});
});
// Check which images are already imported as recipes → disable button
checkImportedRecipes(container);
// Initialize set preview buttons
initSetPreviewHandlers(container);

View File

@@ -183,6 +183,9 @@ function renderMediaItem(img, index, exampleFiles) {
Math.min(maxHeightPercent, aspectRatio)
);
// Extract CivitAI image ID from CDN URL for import status check
const cdnImageId = (img.url || '').match(/\/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)/)?.[1] || '';
// Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
@@ -224,12 +227,25 @@ function renderMediaItem(img, index, exampleFiles) {
// Determine if this is a custom image (has id property)
const isCustomImage = Boolean(typeof img.id === 'string' && img.id);
const hasGenMeta = img.hasMeta || (img.meta && (img.meta.prompt || img.meta.seed || img.meta.resources));
// Create the media control buttons HTML
const mediaControlsHtml = `
<div class="media-controls">
<button class="media-control-btn set-preview-btn" title="Set as preview">
<i class="fas fa-image"></i>
</button>
${hasGenMeta ? `
<button class="media-control-btn create-recipe-btn"
title="Create As Recipe"
data-image-meta="${encodeURIComponent(JSON.stringify(img.meta || {}))}"
data-image-url="${img.url || ''}"
data-image-nsfw="${img.nsfwLevel ?? ''}"
data-image-id="${cdnImageId}"
data-local-path="${localFile ? localFile.path : ''}">
<i class="fas fa-book-open"></i>
</button>
` : ''}
<button class="media-control-btn set-nsfw-btn"
title="Set content rating"
data-media-index="${index}"
@@ -240,7 +256,7 @@ function renderMediaItem(img, index, exampleFiles) {
<button class="media-control-btn example-delete-btn ${!isCustomImage ? 'disabled' : ''}"
title="${isCustomImage ? 'Delete this example' : 'Only custom images can be deleted'}"
data-short-id="${img.id || ''}"
${!isCustomImage ? 'disabled' : ''}>
${!isCustomImage ? 'aria-disabled="true"' : ''}>
<i class="fas fa-trash-alt"></i>
<i class="fas fa-check confirm-icon"></i>
</button>

View File

@@ -432,7 +432,7 @@ export class BatchImportManager {
// Refresh recipes list to show newly imported recipes
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
window.recipeManager.loadRecipes({ preserveScroll: true });
window.recipeManager.loadRecipes(true);
}
// Show results step

View File

@@ -309,9 +309,22 @@ export class BulkMissingLoraDownloadManager {
}, 'warning');
}
// Refresh the recipes list to update LoRA status
if (window.recipeManager) {
window.recipeManager.loadRecipes({ preserveScroll: true });
// Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
if (state.virtualScroller) {
const { extractRecipeId } = await import('../api/recipeApi.js');
for (const recipe of this.pendingRecipes) {
const recipeId = extractRecipeId(recipe.file_path);
if (!recipeId) continue;
try {
const detailRes = await fetch(`/api/lm/recipe/${encodeURIComponent(recipeId)}`);
if (detailRes.ok) {
const updated = await detailRes.json();
state.virtualScroller.updateSingleItem(recipe.file_path, updated);
}
} catch (e) {
console.warn('Failed to update recipe card after LoRA download:', e);
}
}
}
}

View File

@@ -22,6 +22,11 @@ export class DownloadManager {
this.apiClient = null;
this.useDefaultPath = false;
// Batch mode state
this.batchModels = [];
this.isBatchMode = false;
this.editingBatchIndex = -1;
this.loadingManager = new LoadingManager();
this.folderTreeManager = new FolderTreeManager();
this.folderClickHandler = null;
@@ -33,8 +38,12 @@ export class DownloadManager {
this.handleStartDownload = this.startDownload.bind(this);
this.handleBackToUrl = this.backToUrl.bind(this);
this.handleBackToVersions = this.backToVersions.bind(this);
this.handleBackToVersionFromFiles = this.backToVersionFromFiles.bind(this);
this.handleConfirmFileSelection = this.confirmFileSelection.bind(this);
this.handleCloseModal = this.closeModal.bind(this);
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
this.handleBackToUrlFromBatch = this.backToUrlFromBatch.bind(this);
this.handleNextFromBatch = this.nextFromBatch.bind(this);
}
showDownloadModal() {
@@ -80,6 +89,14 @@ export class DownloadManager {
document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions);
document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal);
// File selection step buttons
document.getElementById('backToVersionFromFilesBtn').addEventListener('click', this.handleBackToVersionFromFiles);
document.getElementById('confirmFileSelection').addEventListener('click', this.handleConfirmFileSelection);
// Batch preview buttons
document.getElementById('backToUrlFromBatchBtn').addEventListener('click', this.handleBackToUrlFromBatch);
document.getElementById('nextFromBatchBtn').addEventListener('click', this.handleNextFromBatch);
// Default path toggle handler
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
}
@@ -129,8 +146,12 @@ export class DownloadManager {
this.modelId = null;
this.modelVersionId = null;
this.source = null;
this.selectedFile = null;
this.selectedFolder = '';
this.batchModels = [];
this.isBatchMode = false;
this.editingBatchIndex = -1;
// Clear folder tree selection
if (this.folderTreeManager) {
@@ -150,30 +171,104 @@ export class DownloadManager {
}
async validateAndFetchVersions() {
const url = document.getElementById('modelUrl').value.trim();
const rawText = document.getElementById('modelUrl').value.trim();
const errorElement = document.getElementById('urlError');
const urls = rawText.split('\n').map(l => l.trim()).filter(Boolean);
try {
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
this.modelId = this.extractModelId(url);
if (!this.modelId) {
throw new Error(translate('modals.download.errors.invalidUrl'));
}
await this.retrieveVersionsForModel(this.modelId, this.source);
// If we have a version ID from URL, pre-select it
if (this.modelVersionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
}
this.showVersionStep();
} catch (error) {
errorElement.textContent = error.message;
} finally {
this.loadingManager.hide();
if (urls.length === 0) {
errorElement.textContent = translate('modals.download.errors.invalidUrl');
return;
}
if (urls.length === 1) {
this.isBatchMode = false;
try {
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
this.modelId = this.extractModelId(urls[0]);
if (!this.modelId) {
throw new Error(translate('modals.download.errors.invalidUrl'));
}
await this.retrieveVersionsForModel(this.modelId, this.source);
if (this.modelVersionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
}
this.showVersionStep();
} catch (error) {
errorElement.textContent = error.message;
} finally {
this.loadingManager.hide();
}
return;
}
// Multi-URL batch mode
this.isBatchMode = true;
this.batchModels = [];
errorElement.textContent = '';
const seen = new Set();
const parsed = [];
for (const url of urls) {
const result = DownloadManager.parseModelUrl(url);
if (!result.modelId) {
parsed.push({ url, error: translate('modals.download.errors.invalidUrl') });
continue;
}
// Dedup by modelId + modelVersionId combo so users can download
// different versions of the same model (e.g. latest + a specific version)
const dedupKey = result.modelVersionId
? `${result.modelId}:${result.modelVersionId}`
: result.modelId;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
parsed.push({ url, ...result, error: null });
}
if (parsed.length === 0) {
errorElement.textContent = translate('modals.download.errors.invalidUrl');
return;
}
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
let fetched = 0;
const total = parsed.filter(p => !p.error).length;
this.batchModels = new Array(parsed.length);
const fetchPromises = parsed.map(async (item, index) => {
if (item.error) {
this.batchModels[index] = { ...item, versions: [], selectedVersion: null };
return;
}
try {
const versions = await this.apiClient.fetchCivitaiVersions(item.modelId, item.source);
fetched++;
this.loadingManager.setStatus(`${fetched}/${total}`);
let selectedVersion = null;
if (versions && versions.length > 0) {
if (item.modelVersionId) {
selectedVersion = versions.find(v => v.id.toString() === item.modelVersionId) || versions[0];
} else {
selectedVersion = versions[0];
}
}
this.batchModels[index] = { ...item, versions: versions || [], selectedVersion };
} catch (err) {
this.batchModels[index] = { ...item, versions: [], selectedVersion: null, error: err.message };
}
});
await Promise.all(fetchPromises);
this.loadingManager.hide();
this.showBatchPreviewStep();
}
async fetchVersionsForCurrentModel() {
@@ -197,25 +292,30 @@ export class DownloadManager {
}
}
extractModelId(url) {
static parseModelUrl(url) {
const civarchiveMatch = url.match(/https?:\/\/(?:www\.)?(?:civitaiarchive|civarchive)\.com\/models\/(\d+)/i);
if (civarchiveMatch) {
const versionMatch = url.match(/modelVersionId=(\d+)/i);
this.modelVersionId = versionMatch ? versionMatch[1] : null;
this.source = 'civarchive';
return civarchiveMatch[1];
return {
modelId: civarchiveMatch[1],
modelVersionId: versionMatch ? versionMatch[1] : null,
source: 'civarchive',
};
}
const { modelId, modelVersionId } = extractCivitaiModelUrlParts(url);
if (modelId) {
this.modelVersionId = modelVersionId;
this.source = null;
return modelId;
return { modelId, modelVersionId, source: null };
}
this.modelVersionId = null;
this.source = null;
return null;
return { modelId: null, modelVersionId: null, source: null };
}
extractModelId(url) {
const result = DownloadManager.parseModelUrl(url);
this.modelVersionId = result.modelVersionId;
this.source = result.source;
return result.modelId;
}
async openForModelVersion(modelType, modelId, versionId = null) {
@@ -243,13 +343,19 @@ export class DownloadManager {
document.getElementById('versionStep').style.display = 'block';
const versionList = document.getElementById('versionList');
versionList.innerHTML = this.versions.map(version => {
const newList = versionList.cloneNode(false);
versionList.parentNode.replaceChild(newList, versionList);
newList.innerHTML = this.versions.map(version => {
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
// Count model-type files per version
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
const fileSize = version.modelSizeKB ?
(version.modelSizeKB / 1024).toFixed(2) :
(version.files[0]?.sizeKB / 1024).toFixed(2);
((primaryFile.sizeKB || 0) / 1024).toFixed(2);
const existsLocally = version.existsLocally;
const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally;
@@ -282,6 +388,12 @@ export class DownloadManager {
</div>`;
}
const fileBadge = modelFiles.length > 1 && !existsLocally
? `<span class="file-select-badge" data-version-id="${version.id}">
<i class="fas fa-th-list"></i> ${modelFiles.length} ${translate('modals.download.fileSelection.files')} <i class="fas fa-chevron-right badge-arrow"></i>
</span>`
: '';
return `
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
${existsLocally ? 'exists-locally' : ''}
@@ -302,14 +414,23 @@ export class DownloadManager {
<div class="version-meta">
<span><i class="fas fa-calendar"></i> ${new Date(version.createdAt).toLocaleDateString()}</span>
<span><i class="fas fa-file-archive"></i> ${fileSize} MB</span>
${fileBadge}
</div>
</div>
</div>
`;
}).join('');
// Add click handlers for version selection
versionList.addEventListener('click', (event) => {
// Add click handlers for version selection and file badge
newList.addEventListener('click', (event) => {
const badge = event.target.closest('.file-select-badge');
if (badge) {
event.stopPropagation();
const versionId = badge.dataset.versionId;
this.selectVersion(versionId);
this.showFileSelectionStep(versionId);
return;
}
const versionItem = event.target.closest('.version-item');
if (versionItem) {
this.selectVersion(versionItem.dataset.versionId);
@@ -352,20 +473,110 @@ export class DownloadManager {
}
}
async proceedToLocation() {
if (!this.currentVersion) {
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
return;
}
showFileSelectionStep(versionId) {
const version = this.versions.find(v => v.id.toString() === versionId.toString());
if (!version) return;
const existsLocally = this.currentVersion.existsLocally;
if (existsLocally) {
showToast('toast.loras.versionExists', {}, 'info');
return;
}
this.currentVersion = version;
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
document.getElementById('versionStep').style.display = 'none';
document.getElementById('fileSelectionStep').style.display = 'block';
const nameEl = document.getElementById('fileSelectionVersionName');
if (nameEl) {
nameEl.textContent = `${version.name} · ${version.baseModel || ''}`;
}
const container = document.getElementById('fileSelectionList');
container.innerHTML = modelFiles.map(file => {
const meta = file.metadata || {};
const sizeGB = file.sizeKB ? (file.sizeKB / (1024 * 1024)).toFixed(2) : '--';
const isSelected = this.selectedFile?.id === file.id;
const tags = [];
if (meta.size) tags.push(`<span class="file-tag size">${meta.size}</span>`);
if (meta.format) tags.push(`<span class="file-tag format">${meta.format}</span>`);
if (meta.fp) tags.push(`<span class="file-tag fp">${meta.fp}</span>`);
const fileName = file.name || '';
return `
<div class="file-option ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
<div class="file-option-radio">
<input type="radio" name="fileSelection" value="${file.id}" ${isSelected ? 'checked' : ''}>
</div>
<div class="file-option-info">
<div class="file-option-tags">
${tags.join(' ')}
</div>
<div class="file-option-name">${fileName}</div>
</div>
<div class="file-option-size">${sizeGB} GB</div>
</div>
`;
}).join('');
container.querySelectorAll('.file-option').forEach(el => {
el.addEventListener('click', () => {
container.querySelectorAll('.file-option').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
const radio = el.querySelector('input[type="radio"]');
if (radio) radio.checked = true;
});
});
}
confirmFileSelection() {
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
if (!selectedRadio) return;
const version = this.currentVersion;
if (!version) return;
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
document.getElementById('fileSelectionStep').style.display = 'none';
document.getElementById('locationStep').style.display = 'block';
this.proceedToLocationContent();
}
backToVersionFromFiles() {
document.getElementById('fileSelectionStep').style.display = 'none';
document.getElementById('versionStep').style.display = 'block';
}
async proceedToLocation() {
// If editing a batch item's version, save and return to batch preview
if (this.isBatchMode && this.editingBatchIndex >= 0) {
if (this.currentVersion) {
this.batchModels[this.editingBatchIndex].selectedVersion = this.currentVersion;
}
this.editingBatchIndex = -1;
document.getElementById('versionStep').style.display = 'none';
this.showBatchPreviewStep();
return;
}
// In single-URL mode, validate version selection
if (!this.isBatchMode) {
if (!this.currentVersion) {
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
return;
}
if (this.currentVersion.existsLocally) {
showToast('toast.loras.versionExists', {}, 'info');
return;
}
}
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
document.getElementById('locationStep').style.display = 'block';
await this.proceedToLocationContent();
}
async proceedToLocationContent() {
try {
// Fetch model roots
@@ -450,6 +661,7 @@ export class DownloadManager {
targetFolder = '',
useDefaultPaths = false,
source = null,
fileParams = null,
closeModal = false,
}) {
const config = this.apiClient?.apiConfig?.config;
@@ -513,7 +725,8 @@ export class DownloadManager {
targetFolder,
useDefaultPaths,
downloadId,
source
source,
fileParams
);
if (response?.skipped) {
@@ -595,14 +808,123 @@ export class DownloadManager {
this.updateTargetPath();
}
showBatchPreviewStep() {
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
document.getElementById('batchPreviewStep').style.display = 'block';
const validCount = this.batchModels.filter(m => !m.error && m.selectedVersion).length;
document.getElementById('downloadModalTitle').textContent =
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
` (${validCount})`;
const list = document.getElementById('batchPreviewList');
list.innerHTML = this.batchModels.map((item, index) => {
if (item.error) {
return `
<div class="batch-preview-item batch-preview-error" data-index="${index}">
<div class="batch-preview-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="batch-preview-info">
<div class="batch-preview-name">${item.url}</div>
<div class="batch-preview-meta batch-preview-error-text">${item.error}</div>
</div>
<button class="batch-preview-remove" data-index="${index}" title="${translate('common.actions.remove', {}, 'Remove')}">
<i class="fas fa-times"></i>
</button>
</div>
`;
}
const ver = item.selectedVersion;
const firstImage = ver?.images?.find(img => !img.url.endsWith('.mp4'));
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
const fileSize = ver?.modelSizeKB
? (ver.modelSizeKB / 1024).toFixed(1)
: (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?');
const existsLocally = ver?.existsLocally;
return `
<div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}">
<div class="batch-preview-thumbnail">
<img src="${thumbnailUrl}" alt="">
</div>
<div class="batch-preview-info">
<div class="batch-preview-name">${ver?.name || `Model #${item.modelId}`}</div>
<div class="batch-preview-meta">
${ver?.baseModel ? `<span>${ver.baseModel}</span>` : ''}
<span>${fileSize} MB</span>
${existsLocally ? `<span class="batch-preview-local-badge"><i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}</span>` : ''}
</div>
</div>
${item.versions.length > 1 ? `
<button class="batch-preview-change-version secondary-btn" data-index="${index}">
${translate('common.actions.change', {}, 'Change')}
</button>
` : ''}
</div>
`;
}).join('');
list.onclick = (e) => {
const removeBtn = e.target.closest('.batch-preview-remove');
if (removeBtn) {
const idx = parseInt(removeBtn.dataset.index);
this.batchModels.splice(idx, 1);
this.showBatchPreviewStep();
return;
}
const changeBtn = e.target.closest('.batch-preview-change-version');
if (changeBtn) {
const idx = parseInt(changeBtn.dataset.index);
this.openBatchVersionEditor(idx);
}
};
const nextBtn = document.getElementById('nextFromBatchBtn');
nextBtn.disabled = validCount === 0;
nextBtn.classList.toggle('disabled', validCount === 0);
}
openBatchVersionEditor(index) {
this.editingBatchIndex = index;
const item = this.batchModels[index];
this.versions = item.versions;
this.currentVersion = item.selectedVersion;
document.getElementById('batchPreviewStep').style.display = 'none';
this.showVersionStep();
}
backToUrlFromBatch() {
document.getElementById('batchPreviewStep').style.display = 'none';
document.getElementById('urlStep').style.display = 'block';
}
nextFromBatch() {
const validModels = this.batchModels.filter(m => !m.error && m.selectedVersion);
if (validModels.length === 0) return;
this.proceedToLocation();
}
backToUrl() {
document.getElementById('versionStep').style.display = 'none';
document.getElementById('urlStep').style.display = 'block';
if (this.isBatchMode && this.editingBatchIndex >= 0) {
this.editingBatchIndex = -1;
this.showBatchPreviewStep();
} else {
document.getElementById('urlStep').style.display = 'block';
}
}
backToVersions() {
document.getElementById('locationStep').style.display = 'none';
document.getElementById('versionStep').style.display = 'block';
if (this.isBatchMode) {
document.getElementById('batchPreviewStep').style.display = 'block';
} else {
document.getElementById('versionStep').style.display = 'block';
}
}
closeModal() {
@@ -622,26 +944,120 @@ export class DownloadManager {
return;
}
// Determine target folder and use_default_paths parameter
let targetFolder = '';
let useDefaultPaths = false;
if (this.useDefaultPath) {
useDefaultPaths = true;
targetFolder = ''; // Not needed when using default paths
} else {
targetFolder = this.folderTreeManager.getSelectedPath();
}
return this.executeDownloadWithProgress({
modelId: this.modelId,
versionId: this.currentVersion.id,
versionName: this.currentVersion.name,
modelRoot,
targetFolder,
useDefaultPaths,
source: this.source,
closeModal: true,
if (!this.isBatchMode) {
const fileParams = this.selectedFile ? {
type: 'Model',
format: this.selectedFile.metadata?.format || 'SafeTensor',
size: this.selectedFile.metadata?.size || 'full',
fp: this.selectedFile.metadata?.fp,
} : null;
return this.executeDownloadWithProgress({
modelId: this.modelId,
versionId: this.currentVersion.id,
versionName: this.currentVersion.name,
modelRoot,
targetFolder,
useDefaultPaths,
source: this.source,
fileParams,
closeModal: true,
});
}
// Batch download mode
const downloadItems = this.batchModels.filter(m => !m.error && m.selectedVersion && !m.selectedVersion.existsLocally);
if (downloadItems.length === 0) {
showToast('toast.loras.downloadCompleted', {}, 'info');
modalManager.closeModal('downloadModal');
return;
}
modalManager.closeModal('downloadModal');
const batchDownloadId = Date.now().toString();
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
const loadingManager = state.loadingManager || this.loadingManager;
const updateProgress = loadingManager.showDownloadProgress(downloadItems.length);
let completedDownloads = 0;
let failedDownloads = 0;
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'download_id') return;
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
const current = downloadItems[completedDownloads + failedDownloads];
const name = current?.selectedVersion?.name || `#${completedDownloads + failedDownloads + 1}`;
const metrics = {
bytesDownloaded: data.bytes_downloaded,
totalBytes: data.total_bytes,
bytesPerSecond: data.bytes_per_second,
};
updateProgress(data.progress, completedDownloads, name, metrics);
}
};
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
for (let i = 0; i < downloadItems.length; i++) {
const item = downloadItems[i];
const ver = item.selectedVersion;
const name = ver?.name || `Model #${item.modelId}`;
updateProgress(0, completedDownloads, name);
loadingManager.setStatus(`${i + 1}/${downloadItems.length}: ${name}`);
try {
const response = await this.apiClient.downloadModel(
item.modelId,
ver.id,
modelRoot,
targetFolder,
useDefaultPaths,
batchDownloadId,
item.source
);
if (!response.success) {
failedDownloads++;
} else {
completedDownloads++;
updateProgress(100, completedDownloads, '');
}
} catch (err) {
console.error(`Failed to download ${name}:`, err);
failedDownloads++;
}
}
ws.close();
loadingManager.hide();
if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
showToast('toast.loras.downloadPartialSuccess', {
completed: completedDownloads,
total: downloadItems.length,
}, 'warning');
}
await resetAndReload(true);
}
async downloadVersionWithDefaults(modelType, modelId, versionId, {

View File

@@ -662,7 +662,7 @@ export class FilterManager {
// Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes({ preserveScroll: true });
await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
// For models page, reset the page and reload
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
@@ -746,7 +746,7 @@ export class FilterManager {
// Reload data using the appropriate method for the current page
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes({ preserveScroll: true });
await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
}

View File

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

View File

@@ -2876,7 +2876,7 @@ export class SettingsManager {
await resetAndReload(false);
} else if (this.currentPage === 'recipes') {
// Reload the recipes without updating folders
await window.recipeManager.loadRecipes({ preserveScroll: true });
await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'checkpoints') {
// Reload the checkpoints without updating folders
await resetAndReload(false);

View File

@@ -122,7 +122,7 @@ export class DownloadManager {
modalManager.closeModal('importModal');
// Refresh the recipe
window.recipeManager.loadRecipes({ preserveScroll: true });
window.recipeManager.loadRecipes(true);
} catch (error) {
console.error('Error:', error);

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