A model on a switched-off drive lost its creator and license icons in the
modal even though "search by creator" found it: the list payload is
trimmed to id/modelId/name/trainedWords, and the only full-payload source
was the .metadata.json next to the model file.
- carry `creator` in the lightweight list payload so the card/modal
fallback keeps who published the model;
- add a cache fallback to GET /api/lm/{type}/metadata: when the sidecar
cannot be read (offline drive, unmounted share, missing/corrupt file),
rebuild the CivitAI payload from the cache entry and decode
`license_flags` back into the permission fields the icons render from.
The response reports `partial` and `offline` so callers can explain the
degradation instead of silently dropping fields;
- skip the stat() for paths outside every live root in alongside storage
so a dead share cannot stall the modal; centralized storage still reads
the real sidecar and serves the full payload while the drive is off;
- merge the fetched payload into the list data instead of replacing it
(a partial response must never drop cached fields), and show an
offline/partial notice plus a description-tab explanation.
The global context menu's single "Download example images" entry always
posted force=true, i.e. the re-process-all path that ignores the previous
run's state and re-checks every model. The per-model and bulk menus
already expose both choices, so the global entry was the odd one out.
Turn it into a submenu with "Download Missing" (force=false, keeps the
progress-aware skip) and "Re-process All" (force=true), both still
library-wide. The labels reuse the per-model submenu keys, which are
already translated in every locale, so no new i18n keys or placeholders
are introduced.
- Arm hover cycling only from the bare preview area; hovering the card
header or footer disarms the timer, so action buttons and tooltips
stay undisturbed
- Drop the activation delay from 1.5s to 0.8s now that chrome
interactions are protected structurally
- Revise cycle interval options to Fast (1.5s) / Normal (2s, default) /
Relaxed (3s), dropping the rarely useful 5s/8s choices
- Translate the updated cardPreview strings in all 9 locales
Move the version panel out of Help -> Documentation into a dedicated
Settings -> About section. It shows the LoRA Manager, ComfyUI, ComfyUI
Frontend, Python, OS and browser versions, copies the block as Markdown
for issue reports, and links to the issue tracker, wiki, GitHub and
Discord.
Read the values from a new GET /api/lm/environment endpoint instead of
ComfyUI's /system_stats: Python and OS stay available in standalone mode,
while ComfyUI-only versions are omitted there instead of failing the
whole panel. Model Paths is inserted before About so About remains the
last settings section in standalone mode as well.
Cards with a static image preview now crossfade through the model's
example images when the pointer rests on the card, giving multi-concept
LoRAs a richer at-a-glance signature without giving up the single fixed
preview that anchors visual memory of the library.
- Cycling activates only after a 1.5s dwell so ordinary hover actions
(action icons, tooltips) keep working; the card chrome fades out for
an unobstructed view while cycling and returns on mouse leave
- Example metadata and the first frame start preloading at hover time,
and each shown frame warms the next, so mode entry and interval ticks
never wait on the network
- The cover image (usually the preview itself) opens the rotation as
the anchor's stand-in; cycling starts from the second example and the
cover rejoins when the rotation wraps
- Video previews keep their autoplay-on-hover behavior; video entries
inside example lists are skipped, as are NSFW entries under SFW mode
and blurred cards until unblurred
- Honors prefers-reduced-motion, pauses in hidden tabs, and cleans up
when the virtual scroller recycles a card
- New settings: card_preview_cycle (default on) and
card_preview_cycle_interval (2/3/5/8s, default 3s)
Implements #1182
View all local versions answers "every local version of this model", but the
listing request still carried the active folder: versions moved to another
folder silently disappeared, and a group-by-model card's version count (counted
across the library) no longer matched the list it opened.
- suspend the folder filter when the view starts, remember it, and show the root
selection so the sidebar and the grid agree
- restore the folder when the filter is cleared; keep it suspended across a
page reload
- picking a folder while the filter is active now exits it instead of being
silently ignored (the view is library-wide)
- hand a suspended folder back to its own page when another model page clears
the stale filter
- backend contract unchanged: the client simply omits `folder` while the view
is active
Tests cover the query params, folder suspend/restore/foreign handoff and the
sidebar folder handoff.
The settings folder picker could only select existing directories, so
creating a model root meant leaving the UI to create the folder first.
- Add POST /api/lm/create-directory, backed by create_directory() in
py/utils/directory_browser.py: it creates one subfolder of the browsed
directory and rejects names that are not a single safe path segment
(separators, dot names, Windows-invalid characters and reserved names,
over-long names) plus duplicates, missing/non-directory parents and
permission errors, each with a stable error_code.
- Add a New Folder button and an inline name row to the picker; the
created folder is entered immediately so it can be selected with one
click. Esc dismisses the row before closing the modal, and the button
is disabled on the Windows drive-list level where there is no
directory to create in.
- Map the server error codes to localized picker messages.
- Cover the endpoint and the picker flow with backend and frontend tests.
Checkpoints and other large models are scanned lazily: the cache entry has
an empty sha256 with hash_status="pending", so the modal's hash footnote
rendered nothing and the hash could not be copied for external lookups.
- Expose hash_status in the model list responses and pass it to the modal
through the card dataset.
- Render the pending / calculating / failed states in the hash footnote
with a manual trigger, then swap in the calculated hash with its copy
button; the underlying card and virtual-scroll entry stay in sync.
- Add POST /api/lm/{prefix}/calculate-hash, which reuses the scanner's
singleflight hash calculation and persists the SQLite cache so the hash
survives a restart without a rescan.
Preview URLs now carry a cache-busting `&v=<mtime_ns>` suffix, so the media
type could no longer be read from the end of the string. Model cards,
duplicates, recipe cards and modals, the showcase thumbnail and the ComfyUI
preview tooltip all fell into the image branch and rendered `<img>` for video
previews, which browsers cannot decode.
Move the query-aware video detection that already lived in the versions tab
into a shared mediaTypes helper and use it everywhere a server-provided
preview URL is inspected. The web UI and the ComfyUI widget bundle each keep a
copy because they are served from different mounts. Video previews that turn
out to be webm now also get the right `<source type>`.
Add regression coverage for versioned mp4/webm/image URLs.
Fixes#1168
The R/F/D toolbar shortcuts were only wired in PageControls, but the
recipes page uses its own RecipePageControls in recipes.js. The shared
toolbar still rendered the R keycap and the shortcuts sheet listed it,
yet no keydown handler was registered, so pressing R did nothing.
Extract the shortcut handling into a shared ActionShortcuts module and
register it from both PageControls and RecipePageControls. Registration
is idempotent per event-manager source, so rebuilding controls cannot
fire the action twice. F/D still pass through on recipes because the
page has no fetch/download buttons.
Add regression coverage for the recipes shortcut.
The sidebar menus (folder tree, view options) and the page menus (card,
bulk selection, page-global) are independent components that only knew
how to close themselves, so right-clicking a folder left an open card
menu next to the folder menu, and right-clicking a card left the folder
menu behind.
Add a coordinator where every menu registers a closer; opening a menu
first closes all the others. BaseContextMenu takes part automatically,
the sidebar registers its two menus, and the global contextmenu handler
now uses the coordinator instead of hiding three specific menus by hand
(which also covers right-clicks outside the content area).
Menus are `position: fixed`, so only the window can clip them, but they
were placed at the raw cursor position with a naive flip: near the bottom
edge the menu opened below the window (nothing to scroll it into view),
and a tall menu that flipped up could land above the top edge. The
sidebar folder menu and the view-options menu did no viewport math at all,
so right-clicking a folder at the bottom of the tree showed no options.
Add a shared positionFixedMenu helper that picks the side with room,
clamps on both axes, and caps the height (scrolling internally) when the
menu is taller than the window. BaseContextMenu uses it and exposes a
prepareMenu hook so per-card entries are toggled before the menu is
measured; the Recipe/Lora/Checkpoint/Embedding/Other/Workflow menus used
to mutate the menu after positioning and so measured the previous
content. Submenus get the same treatment via positionSubmenu.
Fixes#1167
Shared chrome borrowed the model-library nouns on the Recipes and Workflows
pages:
- The header filter panel is titled per page (`recipes.filter.title`,
`workflows.filter.title`) instead of "Filter Models".
- Bulk guards and move failures use page-specific copy
(`recipes.bulk.noSelection`, `recipes.move.failed`,
`workflows.bulk.noSelection`) instead of "No models selected" / "Failed to
move model(s)", covering the bulk menu, the sidebar drag-and-drop path and
the recipe move client.
- Workflows got a bulk-action config, so model-only bulk entries (send to
workflow, auto-organize, re-import/rematch, copy syntax) stay hidden
instead of appearing unfiltered.
- The workflows page's global context menu no longer offers the model-only
example-image entries.
- MoveManager no longer assumes every API client exposes `modelType`: the
recipe client has none, which made the "use default path" preview throw
and stored the toggle under `use_default_path_undefined`.
Recipes card copy is translatable now too: the card tooltips (favorites,
share, send, delete, blur, NSFW levels) and the delete dialog go through
i18n instead of hard-coded English.
10 new keys (the move-only noSelection key folded into
`workflows.bulk.noSelection`), translated in all 9 locales; no
[TODO: Translate] placeholder remains.
The workflow card menu reused the model-library copy ("Exclude Model",
"Delete Model", "Copy Model Filename"), and the shared delete dialog
hard-coded "Model:" in its info box.
- Workflow-specific labels for copy-filename / move / delete, and for the
delete confirm dialog (title, message, info-box label, failure alert)
- Move dialog title, root label and hint now go through i18n instead of
hard-coded English; bulk delete dialog switches to workflow copy
- Failed move / nothing-selected toasts use workflow wording
- "Copy Workflow Filename" now copies the file name; it used to click the
card's copy icon, which copies the workflow document (duplicate of
"Copy Workflow")
- Fix "Link to CivitAI" posting to /api/lm/loras/relink-civitai: the
mixin's prefix map was keyed by singular type and missed the plural
page type used by WorkflowContextMenu
Also drops the "Exclude Workflow" action: workflows, like recipes, have
no exclusion concept. The file has no consumer outside LM, and the
excluded view is only reachable from the global context menu, so
excluding a workflow was a one-way hide. The entry is removed from the
card menu, the global menu and the excluded-view banner; the backend
route stays available.
The <=600px header collapsed the search to a floating 32px circle, which
left ~250px of empty bar at 600px and hid the primary action behind a tap
even though the width supported a real field.
- The phone breakpoint moves to 520px: at 521px the icon nav and the
200px search floor still coexist, and 480-600px keep their tabs.
- Below that the search is an always-visible pill that fills the row and
the current page name sits next to the logo, so the bar still says
where you are now that the tabs live in the menu.
- Drops the click-to-expand overlay and its Escape handler: no fixed
positioning, no transparent placeholder, no breakpoint duplicated in JS.
- Active menu entry uses an accent tint; the hover token it used before
has no dark-mode value.
Verified 320-1920px: no clipping or overlap, page title appears at
381-520px and the menu carries all seven tabs.
Below 600px seven tabs plus a usable search field no longer fit. The bar
now reads logo | search | menu:
- The nav entries are repeated in the hamburger dropdown (shown there
only at <=600px) and the main nav is hidden.
- The search collapses to a round magnifier and expands into a fixed
overlay covering the header row on focus; Escape or a click elsewhere
returns it to the button.
- SettingsManager.setNavEntryVisible keeps both copies of the opt-in
entries (Other, Workflows) in sync when the switches are flipped.
- The dropdown scrolls when its 12 entries do not fit the viewport.
Verified in a headless browser from 320px to 1920px: no clipping and no
overlap, and the menu keeps its five actions above 600px.
New card context menu entry 'Restore Downloaded Version' discards local
edits made via the write-back flow:
- Container artifacts (.zip/images) were never modified — restore deletes
the derived .workflow.json sidecar and re-extracts from the pristine
archive, then re-syncs analysis metadata. Purely local, works for
self-hosted workflows too.
- Bare .json artifacts are rewritten in place by write-backs, so restore
re-downloads the recorded CivitAI version file (API-key auth, temp file
+ workflow validation + SHA256 check before the atomic replace).
The menu entry is disabled for local-only .json workflows that have no
restore source. On success the card is refreshed in place with the
shared 'Updated' cue.
Add an Update from ComfyUI entry to the workflow card context menu,
enabled only when the workflow is the active graph in a connected
ComfyUI tab and carries unsaved edits.
ComfyUI side (workflow_writeback.js): the first graphChanged after a
load/write-back flips a dirty flag inside the extra.lm_source marker
(ChangeTracker only dispatches on real state changes); the active
graph's link state (file_path, sha256, dirty) is reported to
POST /api/lm/workflows/comfyui-state on load, tab switch, dirty flip
and write-back. A new lm_collect_workflow WS handler answers LM-page
write-back requests: it reuses the regular PUT content flow and posts
the outcome to /api/lm/workflows/collect-result.
Backend: an in-memory WorkflowLinkRegistry holds per-client link states
(pruned against live websocket sids) and pending collect futures;
POST /api/lm/workflows/update-from-comfy targets the tab holding the
requested file (dirty holders win) and awaits the collect result,
mapping no_changes/cancelled to 200, timeout to 504.
Workflows load a whole graph document rather than inserting into the
current graph, so all workflow entry points (card icon, context menu,
modal button) now consistently read Open in ComfyUI; model types that
insert into the current graph keep Send to ComfyUI.
The card send/copy icons and the model modal's Send to ComfyUI button
only had lora/checkpoint/embedding branches; workflows fell through to
a silent no-op (modal) or lora-ish behaviour with wrong tooltips (card).
- shared helpers sendWorkflowToComfy / copyWorkflowToClipboard in
utils/workflowActions.js, reused by the context menu, card icons and
the model modal
- card send icon opens the workflow in the running ComfyUI graph; the
copy icon copies the workflow document JSON for paste-to-load
- correct tooltips (Open in ComfyUI / Copy workflow to clipboard)
- send entry points hidden in standalone mode (no graph to push into):
card icon and modal button, matching the context menu
- GET /api/lm/workflows/content returns the extracted workflow document
(json direct, zip/png/webp via the derived sidecar); no ComfyUI needed,
so it also serves standalone mode where send-to-comfy is unavailable
- workflow cards gain a Copy Workflow context-menu action that puts the
document JSON on the clipboard for paste-into-ComfyUI loading
- the Open in ComfyUI item is hidden in standalone mode, where pushing
to a graph is impossible by definition
Add a Workflows page that manages ComfyUI workflow JSON files as a
first-class library alongside loras/checkpoints/embeddings:
- opt-in via the new 'Workflow Management' settings toggle (default off)
- dedicated workflow folder roots with sidecar/preview support, separate
from ComfyUI's own workflow storage; 'Send to ComfyUI' loads a workflow
straight into the graph
- workflow scanner parses the graph document (UI/API format sniffing,
node classes, model/civitai refs) and persists the analysis in the
SQLite model cache
- settings UI: workflow folder paths editor in the Library tab and the
standalone Model Paths page
Fixes from sandbox E2E verification:
- include the workflow scanner in startup initialization so the page no
longer hangs on 'initializing' when enabled without folders
- add the missing workflows entry to the frontend page-state map (page
init crashed and the sidebar fell back to the loras tree without it)
- project workflow analysis fields through the scanner cache entry and
persist them in the model cache (new columns with auto-migration)
- keep symlinked paths intact in the directory picker (abspath instead
of resolve), so stored library paths match what the user sees on disk
Also rebuilds the vue-widgets bundle to sync it with web/comfyui/utils.js.
The initialization page computed its ETA by regex-parsing the English
details strings ("Found N", "Processing x/y") and the progress bar
advanced per file, so one 7 GB LoRA and one 100 KB embedding moved it
by the same amount.
The scanner progress callbacks now carry the collected file sizes end
to end: process_models/count_models broadcasts include structured
processed/total/bytes_done/bytes_total fields, and the bar percentage
is weighted by bytes (falling back to file counts when sizes are
unavailable). initialization.js consumes the structured fields
directly - no more string parsing - and computes the remaining-time
estimate per byte, keeping the file-count path as a fallback.
Two initialization-page defects made failures look like progress:
- A cache read exception while rendering a model/recipe page forced
is_initializing=True, showing an endless spinner; worse, the polling
fallback then reported the idle scanner as "complete", reloading the
page straight back into the same exception. Page handlers now pass
init_error to the initialization template, which renders the error
state (with retry) server-side, and initialization.js stays on it
instead of starting WebSocket/polling.
- The WebSocket path reloaded once the page's own scanner finished,
while the polling fallback waited for every scanner. The polling
fallback now mirrors the per-page semantics: /api/lm/init-status
accepts ?page=<pageType> (aggregate behavior preserved without it),
and ModelScanner records _init_error so a failed initial scan is
reported as "error" instead of masquerading as "complete". Polling
stops on error instead of reloading into the failure.
Fresh installs no longer start a full library scan (with per-file SHA256
and sidecar writes) automatically at startup. Instead a welcome page
lists the configured model roots, estimates the scan cost from a quick
hash-free walk, and lets the user pick the sidecar storage layout and
UI language before confirming. Deferring shows an empty library; the
existing refresh button starts the scan later. Upgrading installs are
detected via a non-empty persisted cache snapshot (row-count check, so
the empty file created by scanner construction no longer masquerades
as an existing library) and keep the old behavior.
Bundled fixes surfaced by the new flow:
- broadcast an init-progress error when the startup scan fails instead
of silently landing on an empty library; the initialization page now
shows the error with a retry button
- skip the onboarding language modal/tutorial while the welcome or
initialization page is up (its spotlight targets don't exist there,
so the tour was silently auto-completed and never shown)
- promotional banners wait until onboarding is completed or skipped;
alert banners (startup messages, cache health) are unaffected
- honor the hidden attribute in first-run/initialization CSS so the
no-roots hint and error boxes don't render as empty frames
Deleting a duplicate could remove the file from disk while leaving the
entry in the cache, so the duplicate kept showing after a refresh and
only Rebuild Cache cleared it (issue #1154). Three paths produced that
state:
* ModelHashIndex.remove_by_path() deleted _hash_to_path[hash_val] with no
guard. The delete flow passes the cache row's sha256 as a hint; once
the row and the index had drifted apart (a hash corrected in place, a
partially applied cache update) the hint was not in the index and the
KeyError aborted _batch_update_cache_for_deleted_models(), so every
deleted model stayed in memory and in SQLite.
* The same cache update was skipped outright when the scanner's
cancellation flag was set, turning files staged before the cancellation
into ghost entries.
* A file that was already gone from disk could never be cleaned up:
staging found no artifacts, the hard delete reported nothing, and the
row survived as a permanent "No files deleted" failure.
Make the hash hint advisory and guard the index deletions; drop the
path-keyed entries (filename duplicates, autov2/autov3) even when no hash
mapping exists. Always apply the cache update after a delete, purge stale
index paths that never reached the cache, and treat a missing file under
a reachable folder as deleted while still keeping unreachable roots
offline. bulk_delete_models() reports success: false when nothing was
removed instead of presenting a no-op as a completed delete.
The duplicates UI now surfaces what it used to hide: per-file failures
from `results`, and a cache-update failure that needs Rebuild Cache.
The #1148 fix made performDragMove() resolve the library root from
GET roots before reading the dragged paths. The browser dispatches
dragend right after drop, and handleCardDragEnd() calls
resetDragState(), so by the time the roots response arrived
draggedFilePaths was null and `this.draggedFilePaths.length` threw:
TypeError: Cannot read properties of null (reading 'length')
at SidebarManager.performDragMove (SidebarManager.js:711)
The throw happened before the try block, so no toast was shown and no
move request was ever sent -- drag-drop silently did nothing. The same
race hit the draggedRootPath fallback (aborting the move when the roots
request failed) and the draggedFromBulk cleanup (bulk mode stayed on
after a bulk move).
Snapshot filePaths / fromBulk / fallbackRoot before the first await and
use only the snapshot afterwards; _resolveMoveRoot takes the fallback
root as an argument instead of reading this.draggedRootPath.
PageControls.syncExcludedViewState() un-hid #modelTypeDropdown whenever
the excluded view was inactive, racing the dropdown's async init. On
pages with a single sub-type (e.g. checkpoints with only "checkpoint"),
init returns early, leaving a bare caret trigger with no label and no
event handlers.
Let ModelTypeDropdown flag data-model-type-available after a successful
init and restore visibility from that flag, so single-type pages stay
hidden while excluded-view transitions still hide and restore an actual
dropdown. Add regression tests for both the single-type and excluded-view
paths.
Counts in the filter panel chips (base models, tags, model types), the All Types
dropdown and the Refresh scope rows were rendered as raw digits, so a library with
10k+ models turned every chip into a five digit pill that dominated the row it sat
in — and the Refresh menu grouped its numbers while the filter panel did not.
One formatter now renders all of them (`formatCount`): exact and locale-grouped
below 10,000, abbreviated to three significant digits above it, and abbreviated
only where the locale's Intl compact form is a real unit and narrower than the
plain number — German keeps 18.475 instead of the rounded 18.500, Russian keeps
18 475 instead of the wider 18,5 тыс.
Their hover text is always present and always exact ({name} — {count} models, and
... recipes on the Recipes page): an affordance that only exists above a threshold
is undiscoverable, and the tooltip is also what reveals a chip label that CSS has
ellipsized.
Chips gain a two-slot layout (the name ellipsizes, the count keeps a tabular
right-aligned slot) so a long tag name can no longer push the count out of the
chip; Refresh rows move from float:right to a flex row, which keeps the counts in
a right-hand column and lets a long root label ellipsize. The All Types group
tooltip moves onto its trigger so it stops leaking onto the menu options.
The two new strings ship as [TODO: Translate] placeholders in the nine locales;
the translation pass is the next commit.
Width pressure on the controls bar is real (see the max-width: 1500px
tightening in layout.css), and the icons on text-labeled action buttons
are redundant encoding. Remove them from Refresh / Fetch / Download /
Bulk / Duplicates / Doctor and the recipes-page Import / Batch Import.
Kept: state-carrying icons (favorites star, updates exclamation,
exit-duplicates fa-times, badges) and structural icons (carets, layout
toggle, dropdown menu items). ModelDuplicatesManager rebuilds the
duplicates button on mode exit, so its restore string is updated too.
Single-select Model Type dropdown next to the sort control on the loras,
checkpoints and other pages (hidden when only one sub-type exists).
Selection maps to filters.modelTypes, persists via the existing filter
storage, and stays in sync with the filter panel's multi-select chips.
The sidebar drag-and-drop derived the library root by stripping the card's
cached folder off its file path. Any disagreement between the two -- a stale
or wrong-cased folder recorded by an older version -- made the strip fail and
silently returned the file's own directory as the root, so the move created
<current dir>/<target path> on disk and physically nested the library.
Two defenses:
* SidebarManager now resolves the move root from the authoritative library
roots (longest prefix match) and only falls back to the card heuristic,
which compares case-insensitively and aborts the move on a real mismatch
instead of fabricating a root.
* Reconcile repairs existing entries whose folder no longer matches where
their file_path sits. Refresh never rewrote existing rows, so folders
recorded wrong by 1.2.2-1.2.5 kept poisoning moves forever; now a plain
Refresh heals them (no manual Rebuild Cache needed).
Replace the browser's default full-card drag snapshot with a small chip
(thumbnail + name + count badge) anchored below-right of the cursor, so
the folder tree stays visible while aiming. Collapsed folders now
auto-expand after a short hover during a drag, and the tree auto-scrolls
when the pointer nears its top/bottom edge.
Distinguish same-version quantized models (e.g. GGUF Q4_K_M vs Q8_0) on
cards, which previously showed only the identical CivitAI version name.
Detection is content-based and stored as a three-state sidecar field
(absent = unchecked, "" = checked-unavailable, value = canonical name),
mirroring the autov3 pipeline:
- GGUF: minimal pure-struct header parser reads general.file_type
- safetensors: dominant tensor dtype F8_* -> FP8
- CivitAI files[].metadata.fp == "fp8" fills in as a fallback only
- A one-time backfill service covers existing libraries; each file
costs only a few-hundred-byte header read
The footer renders the badge after the version name (suppressed in
grouped mode), gated by a new show_quantization_on_card setting that
follows the show_version_on_card toggle pattern.
The dialog sits in the sidebar and now offers the removal itself, so telling the
user the entries "can still be removed from the sidebar" said the obvious twice
and read oddly next to a button that does exactly that. Say what is true and stop:
"This folder no longer exists on disk. You can remove it from the list."
The result toast follows the button's wording ("removed from the list") instead of
naming the sidebar again. Both strings changed in English, so the existing
translations of them are back in the queue with the new source text rather than
left saying "refresh the sidebar and try again".
Deleting a folder a file manager had already moved away answered "Folder no longer
exists" and stopped there: `remove_known_folder()` only ran after `shutil.rmtree()`
succeeded, so the records keeping the node in the tree could never be dropped, and
the modal's missing state hid its confirm button. The sidebar was a dead end for
exactly the case the folder list cannot self-heal.
* `ModelMoveService.forget_folder()` drops a folder's records (the recorded entry
plus the cached models under it) and refuses with `code: "exists"` while any root
still holds the directory, because then the caller wants a delete.
* `delete_folder()` forgets the records instead of failing when the directory is
already gone. The dry run stays a pure read and keeps answering `missing`, which
is what makes the dialog offer the removal. `POST`/`GET
/api/lm/{prefix}/forget-folder` is the endpoint for a node that maps to no
directory at all (the GET form is for the browser extension, which is GET-only).
* The sidebar falls back to it from the delete flow, offers "Remove from list" in
the missing state, and reports the folder as removed from the sidebar rather than
as a delete that never happened. An `exists` conflict says the directory is back
and should be deleted instead.
Verified: 3811 backend tests, 1543 JS tests, 96 Vue tests, 20 i18n tests.
Centralized sidecar storage moves .metadata.json into a mirror tree, so
the Location link in the model modal could no longer reveal a model's
sidecar. The Location row now shows an inline metadata icon button
(centralized mode only) that calls a new
POST /api/lm/models/open-sidecar-location endpoint: the backend resolves
the sidecar through py/utils/sidecar_paths, reveals the .metadata.json
selected in the file manager (falling back to the sidecar directory when
it has not been written yet), and returns the path for clipboard mode on
headless/Docker sessions. The platform reveal logic is factored into a
shared FileSystemHandler._reveal_file helper. All 9 locales translated.
The default recipes dir (<first lora root>/recipes) is auto-created by the
recipe scanner and recorded by the model-root walks as an empty folder, so
it showed up in the folder sidebar — and since recipe files are not model
weight files, the folder delete endpoint's model_count check let a
right-click delete wipe the whole recipe library via shutil.rmtree.
- scan walks (full scan, reconcile, all-folders backfill) no longer descend
into the effective recipes dir, and get_all_folders() filters it out of
snapshots persisted before the exclusion existed
- delete_folder/rename_folder refuse the recipes dir and any ancestor of
it with code "protected"
- the delete modal renders a protected state (confirm hidden, per-row
checkbox disabled) instead of a deletable 'no models' prediction
- RecipeScanner.recipes_dir now shares the side-effect-free resolver in
py/utils/recipes_paths.py
The Refresh menu can scope a scan to one model root, but the folder a user is
looking at lives in the sidebar's unified tree, which merges every root into one
relative-path namespace. "Scan this folder" therefore addresses the folder, not a
root: the backend walks that relative path under every root that holds it, which
is also what makes the action safe while another drive is switched off.
Backend:
* GET /scan accepts `folder=<rel>` (alone or with `roots=`), rejected together
with full_rebuild=true like the roots parameter. Validation reuses
normalize_relative_folder(), extracted to module scope from ModelMoveService so
the folder operations and the scan endpoint reject the same input (absolute
paths, drive letters, `..` climbing) instead of each carrying its own copy.
* The reconcile summary carries `scope_label` (the folder) for a folder scope, so
the result toast names the folder the user clicked instead of the roots it
happens to live under; the completed WS payload carries it too.
* `folder` is a scope prefix exactly like a root: only that subtree is re-read or
pruned, and an unreachable root keeps the entries that fall inside it.
Frontend:
* The sidebar folder context menu gains "Scan this folder" above "Check for
updates in this folder" (they share the refresh divider); the entry is gated by
the same supportsFolderManagement flag as the other folder operations.
* SidebarManager.scanFolder() resolves the node through the existing
_resolveFolderCandidates() before doing anything: a folder no root holds any
more explains itself ("no longer exists on disk") instead of scanning nothing,
and an unresolvable multi-root node is refused rather than guessed.
* PageControls.refreshModels() and BaseModelApi.refreshModels() forward the folder
scope, and _showRefreshSummary() prefers scope_label over the walked roots.
Verified live on the three-root sandbox with one drive switched off:
GET /scan?folder=pack000 walks drive-G and drive-Y, reports scope_label=pack000,
keeps drive-Z's 6 entries under that folder (kept_unreachable=6) and leaves all
420 models cached. 3704 passed, 7 skipped; frontend 1495 passed (150 files); vue
widgets 96 passed. The 2 new sidebar keys are [TODO: Translate] placeholders
pending the feature owner's go-ahead.
Refreshing had no way to say "scan only this drive": a user with three external
drives had to spin all of them up for every refresh, and switching a drive off
made the next refresh treat its whole library as deleted (rows pruned from the
memory cache and the SQLite cache, preview_url stripped on the next scroll).
Backend (py/services/model_scanner.py, py/config.py):
* ReconcileScope(roots, folder) + _reconcile_cache(scope=...): files inside the
scope reconcile normally, everything outside is neither re-read nor removed.
The folder half is plumbing for the sidebar entry in the next change.
* Path-level pruning guard: cached entries under a path this walk could not
read are kept and reported instead of removed. Sources: a configured root that
is not reachable (drive switched off while LM runs), a directory os.walk
failed to enter (permissions / I/O error / Windows junction to an offline
drive), and a known first-level symlink whose target is gone
(Config.iter_path_mappings()).
* The recorded folder list is unioned instead of replaced whenever the scan did
not verify every root, so a scoped scan cannot empty the sidebar.
* _reconcile_cache returns a summary (added / removed / repaired /
scanned_roots / skipped_roots / unavailable_paths / kept_unreachable),
exposed as ModelScanner.last_reconcile_summary, returned by
BaseModelService.scan_models() and broadcast in the completed WS payload.
* _root_display_labels(): set-aware labels ("G: loras", "usb/loras") grown
leftwards with real parent segments until unique, shared by the walk-progress
line and the roots API.
* GET /scan accepts repeated `roots` (400 for unknown roots, 400 combined with
full_rebuild=true); GET /roots gains root_details (label / reachable / cached
count) while `roots` stays a plain path list for existing callers.
* serve_preview: a 404 no longer clears the cached preview_url when the file's
own directory is unreachable - browsing the grid with a drive off used to
strip preview references from the persistent cache.
Frontend:
* Refresh ▾ gains a "Scan one folder" section listing the page's roots with
their cached counts; offline roots stay clickable and explain themselves; rows
are wired by delegation (new static/js/components/controls/ScanScopeMenu.js).
* A scoped scan reports "Scanned <root>: N new, M removed"; a scan that kept
entries reports "<N> models kept: <paths> not reachable".
* registerAPI() now injects the two cross-page passthroughs (fetchModelRoots and
an argument-forwarding refreshModels) so a page facade cannot drop them: the
first version rendered an empty menu and would have run a full refresh.
* createToastElement whitelists toast types, so a wrong `type` argument degrades
to the info style instead of rendering an unstyled box.
Verified in a sandbox instance with three roots: a scoped scan walks only the
requested root (progress roots=0/1, 240 files); a full refresh with one root
offline reports kept_unreachable=60 and leaves all 420 models cached; /roots
reports the offline root with its cached count. 3699 passed, 7 skipped;
frontend 1488 passed (148 files); vue widgets 96 passed.
The sidebar's folder tree merges every model root into one relative-path
namespace, but folder operations turned a node into a path by prefixing
default_*_root. A folder living under another root failed to delete with
"Folder no longer exists" (recipes under the primary lora root while
default_lora_root is the extra one), and where the same relative folder
exists in both roots the operation silently hit the other copy — 14 of the
16 top-level folders in the reporting library are shared, so guessing a root
was never safe.
Backend:
* ModelMoveService.resolve_folder() and GET /api/lm/{prefix}/resolve-folder
answer which directories a library-relative folder maps to
(folder_path/root/is_symlink), in scanner root order, skipping directories
no root holds and refusing absolute or climbing paths.
* delete_folder/rename_folder tag a vanished directory with code "missing"
so the sidebar can tell "this node is stale, refresh" from a failed
operation.
Frontend:
* _resolveFolderCandidates() is the single place that turns a node into
absolute paths: default root first, the old root-prefix fallback only
while a single root is configured, and an explicit unresolved error for a
multi-root library — nothing is guessed silently any more.
* One copy keeps the single-target modal, which now names the resolved
absolute path. Several copies render one checkbox row per copy, each
dry-run against the delete guard ("no models" / "contains N model
file(s)..." / a deletion is still pending / no longer exists / symbolic
link): a blocked copy is unticked, disabled and explained, the button
reads "Delete N folders", every ticked copy is deleted and guarded on its
own, and a partial failure is reported without discarding the successes.
* Rows are built once per open and only their status text is updated, so
ticking a box no longer rebuilds the list, steals focus or resizes the
modal mid-click; the action row keeps a fixed button width.
* Rename offers a root picker in its inline row, create inherits the
parent's root when the parent resolves to exactly one directory, and the
undo restores every copy a delete removed.
i18n: 26 new keys (sidebar.deleteFolderModal.*, .deleteFolderResult.*,
.renameFolderResult.*, .folderRoot.*, .folderResult.*) translated in all 9
locales, with the en wording normalized to the established "model root" noun
(it had said "library root") and the new terminology recorded in
docs/i18n-translation-guidelines.md.
Verified: pytest 3686 passed, vitest 1473 passed (86 in the folder-management
suite), pytest tests/i18n 20 passed, sync_translation_keys.py --dry-run
clean. A sandboxed standalone instance with two roots confirmed that deleting
one copy leaves the node in place, that the twin's model card survives the
purge, and that deleting both copies and undoing restores both directories.
Bring back the pre-v1.2.2 classic vertical list for example images as a
persisted showcase_layout setting (gallery stays the default), switchable
from both a Settings select and an in-modal segmented toggle.
- Revive the vertical list renderer (adapted from 4a6042d0) as
VerticalListView.js, reusing the shared MediaUtils/MetadataPanel
infrastructure; legacy CSS scoped under .showcase-vertical
- Dispatch showcase rendering on state.settings.showcase_layout;
collapsed bar, empty/filtered states and import flow stay shared
- Keep the back-to-top button available in vertical mode (the
showcase-expanded class that hides it now only applies to the gallery,
whose thumbnail strip occupies that corner)
- Persist the setting via DEFAULT_SETTINGS + DEFAULT_SETTINGS_BASE and a
new select under Settings > Layout Settings
- i18n: 6 new keys translated in all 9 locales; terminology recorded in
docs/i18n-translation-guidelines.md
Closes#1136
The owner could not tell from the UI what "Buzz Price Tracking" enabled, what the
"Price alert threshold" number meant, or what "Price Alerts" was alerting about.
That was not a copy problem: the implementation exposed our mechanism (a page
scrape) and our SQL predicates as the user's concepts. Two concrete defects came
from the same root:
- the alert population included versions the user already owns (neither the event
generator nor the query filtered on is_in_library; in the owner's library 28 of
52 gated versions were already downloaded, so most "alerts" were about files
already on disk, which cannot become cheaper *for them*);
- a threshold-filtered state list lived in a notification surface, so an empty
panel had three indistinguishable causes and read as a broken feature.
The information model is now the version plus ownership: cost is shown only where
a decision exists. Owned -> nothing. Not owned and free -> nothing. Not owned and
gated -> the price when it is known, `Paid` without a number when it is not, and
early access keeps its countdown because "free on <date>" decides between waiting
and paying. The numeric threshold has no place in that model: every decision is
categorical (wait / pay / skip), so the setting, the comparison and the whole
alert-state machine are gone.
- both alert-state columns are removed from the schema rather than left dead; a
database created by an unreleased build has them dropped on open (native
ALTER TABLE ... DROP COLUMN, guarded), which is a no-op for everyone else
- gate events are emitted only for versions the user does not have, and the
price-drop event goes with the threshold it belonged to
- both alert endpoints, PriceAlertsHandler and the service-registry adapter field
it needed are removed: events already reach the UI through the refresh response
- the bell tab, panel, CSS, both entry points, the unread watermark and their
locales are removed; the setting keeps only the enable flag and the refresh
interval and is framed as plumbing
- "Price unavailable" is replaced by `Paid`: the gate is certain from the public
API, only the number is best-effort, and that is our plumbing, not the user's
problem
Verified against a copy of the owner's real database: 52 gated versions ->
28 owned (now silent) + 24 the feature is actually about; the drop migration ran
and both removed endpoints 404.
End-to-end verification against the live site found the price capture broken for
a whole class of users: the civitai page hosts are not interchangeable, and the
user's civitai_host preference was silently fatal. With civitai_host=civitai.red
the update DB held zero prices even with tracking enabled.
- civitai.red refuses non-browser HTTP clients outright (Cloudflare challenge,
403 for any User-Agent, aiohttp and httpx alike), while civitai.com and
civitai.green answer normally for anonymously visible models and 404 for
mature ones. An earlier manual check with curl passed on TLS fingerprint luck,
which is why this was missed.
- get_model_prices now tries the configured host first, then the others, and
takes the first parseable payload. The host that worked is remembered, and a
host that refuses outright is parked for 15 minutes so a library full of
mature models does not pay three requests each; a 404 is model-specific and
does not park the host. Links keep using the configured host, which is where
the user's own browser has clearance.
- Mature models still have no price source anywhere, so that is now stated
instead of silent: price_check_attempted_at separates "tried and unreadable"
from "never looked", gated versions show a muted "Price unavailable" badge,
and the alerts panel reports unavailableCount.
- Failures are logged at warning level, once per host per TTL, with the
per-host reason, instead of only at debug level.
- The recorded alternatives (internal tRPC with the user's API key, or an
extension-assisted fetch from the user's browser) and the strengthened
upstream ask for a public price field are documented in the plan.
P5a of docs/plans/paid-model-price-tracking.md: one surface that answers "what
got cheaper / became free", without a permanent button (the grid filter was
dropped by owner decision, so the panel carries the actions itself).
- price_alert_since records when an alert started, so the panel can say
"dropped 3 d ago" and count what is new since the user last looked; it is set
on the first sight of an already-cheap version, preserved while the alert
stands, and cleared when the price rises back above the threshold
- get_price_alerts() compares the threshold at read time (editing it takes
effect immediately, no refresh needed) and returns both kinds in one list;
model_type=None covers every type, which the shared update DB makes a single
query. "became free" needs no price data, so it is reported even while price
tracking is off
- GET /api/lm/price-alerts, registered once in MiscRoutes rather than per model
type, decorating rows best-effort with the local model name and file path from
the scanner indexes (a cold cache just omits them)
- a third tab in the notification bell: segments for under-threshold and
became-free, the three states (tracking off / nothing matching / stale), and
per-row actions (CivitAI always, Open when the model is local)
- two non-permanent entry points share one helper: the controls-bar updates
dropdown and the global context menu, whose label carries the unread count
- unread state stays client-side (localStorage watermark); the count is fetched
once on init and only when price tracking is enabled
- the per-type frontend client method is removed as dead code; the per-type
backend route stays for the companion extension
CivitAI's public API deliberately omits prices — paidAccess is trimmed to
{permanent, endsAt} because "pricing belongs to the purchase flow" — but the
public model page embeds the site's own model.getById result, including
paidAccess.terms, in its server-rendered payload. That is read anonymously
(no API key, no internal endpoint, no forged Origin), one request per gated
model, so only the ~2% of models that actually carry a gate pay for it.
- optional capture, off by default: price_tracking_enabled,
price_alert_threshold_buzz (0 = alert on "became free" only) and
price_check_ttl_hours; prices refresh on their own TTL and immediately when a
gate changes, and a failed fetch keeps the stored price instead of blanking it
- versions that stop carrying a gate are marked free (persisted gate_lapsed_at)
and gate transitions are reported as events on the refresh response, so a
version already in the library can announce that it became free
- price_alert_state plus a price_drop edge event; new
GET /api/lm/{type}/updates/price-alerts lists what is under the threshold
- versions tab shows the price (effective, with the list price struck through
and a Blue Buzz note) and a Free Now badge; an update check toasts the
transitions in one message
- the parser and the alerts query are unit-tested against a trimmed page
fixture, and every route definition is now asserted to resolve to a handler
Plan, verification notes and the deviations from it are in
docs/plans/paid-model-price-tracking.md.