The first draft read as a design document for the CivitAI team: it explained how
to change toPublicPaidAccessDto, cited discountedTerms, and described how we read
prices from model pages today. Two of those do not belong in an issue.
- no implementation guidance: they know their service, and withholding prices may
be a deliberate product decision (the code comment says pricing belongs to the
purchase flow), so the request has to argue the need rather than the diff
- no description of our current page reading: it is our approach, it shifts the
thread from the feature to our behaviour, and it invites an objection that has
nothing to do with the ask
The filed text is now 138 words: ask, why (the API gives the gate and the early
access end date but not the price, which is the other half of "wait or pay now"),
one credibility clause naming the integration, and one scope concession (public or
authenticated-only). The evidence table and the write/read asymmetry stay in the
plan as internal notes, explicitly marked as not for the issue.
No existing civitai/civitai issue asks for this (searched paidAccess, "download
price", "buzz price API" and every open [API Feature Request]), so the draft is a
new issue using that repo's title convention.
The argument leans on how small the change is there: toPublicPaidAccessDto
already receives a PaidAccessRow carrying `terms` and `sales` and returns only
{permanent, endsAt}; discountedTerms already resolves sale prices; and the write
path already accepts the same terms via updateModelVersionPaidAccessSchema. The
reads withhold exactly what the writes accept.
It also documents why the page is not a workaround: civitai.red challenges
non-browser clients (403 for any User-Agent), while civitai.com and civitai.green
404 mature models to anonymous visitors, so mature models have no readable price
source at all.
Also marks the P4 upstream task as drafted.
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
無料になりました (8 characters) is a sentence, not a badge label: it sits next
to 有料 (2) and 早期アクセス (6) in the version row. 無料化 is three characters
and names the state transition this badge actually marks - it only appears when
a version's gate lapsed, never for a version that was free all along. The
tooltip keeps the full sentence.
Completes the 13 keys the obtainability feature left as [TODO: Translate], in all
9 locales: the two gate-event counts, the sale / Blue Buzz / "Free Now" / early
access end-date badge strings, and the six settings.priceTracking strings plus the
section header.
- Buzz and Blue Buzz stay as-is everywhere: they are CivitAI currency names, not
translatable words (R3)
- register follows each file's existing norm (你 zh-CN, 您 zh-TW, Sie de, tú es,
вы ru); fr keeps the file's ASCII apostrophe style; no full-width punctuation
leaked into the Latin/Cyrillic/Hebrew locales
- two source fixes came with the pass: the unused settings.priceTracking.label key
is removed (no template renders it - the toggle uses enabled/enabledHelp), and
settings.sections.priceTracking now reads "Buzz Download Prices" so the header
matches what the feature does: prices are displayed, nothing is tracked for
alerts
- the locale files were edited by exact-value replacement rather than re-serialised,
so each diff is 13 lines and the per-file indentation is untouched (R1)
Live model 958009 has 37 gated versions across 6 price points, and the owner's
own library has a model whose two early access versions end on different dates,
so an 'Update - 500 Buzz' badge would be fabricated rather than summarised. The
uniformity that made it look plausible is an artefact of floor pricing. Prices
stay in the version list; only a deadline is well defined at model level.
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.
The default threshold is 0 ("only tell me when a version becomes free") and the
settings copy says so, but the panel did not: a real instance with 52 priced paid
versions and an untouched threshold showed "Nothing is under your price threshold
right now" with only a small "Alert threshold: 0 Buzz" in the corner, which reads
as a broken feature.
- the payload now carries pricedCount, so the empty state can say how many paid
versions already have a known price
- when the threshold is 0 and prices are known, the empty state says so and
points at Settings - Library instead of implying there is nothing to show
- the read-time threshold comparison means setting one takes effect immediately;
measured on a copy of that instance: 0 Buzz -> 0 alerts, 100 -> 44,
500 -> 48, 5000 -> 51
Found in a real instance: after enabling price tracking, a normal "Check updates"
captured exactly one price out of 718 models, so the alerts panel looked broken
while the log said the refresh completed.
Price capture only ran when the version list was re-fetched, so it inherited the
metadata TTL: with 24 h metadata and 24 h price TTLs, only the handful of models
whose metadata happened to be stale that round were ever priced.
- the cached record already carries the gate, so the price pass now runs off
whichever version list is available (freshly fetched or stored) and applies the
result without touching last_checked_at, so a price-only pass cannot silently
extend the metadata TTL
- a failed attempt now satisfies the price TTL, so a mature model whose page no
host will serve is not retried on every single update check
- an explicitly forced check re-prices within the TTL
Verified by copying a real instance's update DB into a sandbox and running a
non-forced check: bulk metadata fetches 0 (version lists entirely from cache)
while priced versions went 1 -> 20 and the panel listed 19 alerts.
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.
CivitAI only returns a non-null paidAccess for an *active* gate: a lapsed gate
stays in the database as a tombstone and is filtered out server-side, so
{"permanent": false, "endsAt": null} — a timed gate whose window end has not
been recorded yet — is still enforced. Verified live: on model 1802980 that
version reports canDownload: false while its lapsed siblings report true.
Both the update service and the download gate dropped that shape, so such
versions read as free and "Hide Early Access Updates" missed them — the class
of bug reported in #1060.
The interpretation now lives in py/utils/paid_access.py and is shared, so the
badge, the update filter and the download warning cannot disagree.
Add OpenModelDB (openmodeldb.info) as a metadata and download source for
the existing upscaler model type.
Metadata:
- New OpenModelDBClient: fetches the site's bulk JSON dumps, caches them
on disk (24h TTL + ETag revalidation), and builds a local sha256 index
- New OpenModelDBModelMetadataProvider adapts catalogue entries to the
CivitAI-shaped version dict contract; registered in the fallback chain
behind the enable_openmodeldb_api setting (default on), gated to the
upscaler sub-type so other model types never trigger the dump download
- Persisted provenance uses metadata_source "openmodeldb" plus a nested
openmodeldb block (page URL, architecture, scale, license)
Images: paired-image LR/SR URLs are ephemeral imgdiff.net sessions, so
displayable images come from the site-hosted auto-generated thumbnails
(model-level cover leads images[], per-image thumbs for the rest); the
original comparison URL is kept in meta.comparisonUrl.
Downloads:
- New OpenModelDBSource (flat model ids, omdb: group prefix) with
resource filename derivation that recovers names hidden mid-path
(mediafire) or synthesizes {id}.{type} for folder links
- HTML-gateway mirrors (mediafire/mega/drive) are rejected with a clear
manual-download hint instead of silently saving an HTML page as .pth
- ModelSource base gains is_valid_source_id / default_subdir_parts /
resolve_download_url hooks so flat-id sources need no platform branches
UI: "View on OpenModelDB" link in the model modal (downloaded and
hash-enriched models), settings toggle next to the CivArchive one.
Applying a filename template to a large library re-did O(library) work for
every renamed file: a full natsort resort plus whole-table SQLite rewrite and
download-history resync after each rename, and a full scan plus resort of the
entire recipe collection per renamed LoRA. On a 20k-model library with 300k
recipes on a HDD this pushed "Apply to Library" into multi-day runs.
- ModelScanner.defer_cache_persist(): bulk loops update the in-memory entry
and indexes only; resort + persist + download-history sync run once at
context exit, forced even on cancellation/error since files are already
renamed on disk. Single-rename callers keep immediate per-call behavior.
- RecipeScanner.build_lora_hash_index(): one-shot hash -> recipes index so
per-file lookups are O(1); update_lora_filename_by_hash gains hash_index /
defer_maintenance params, with a single finalize_bulk_filename_updates()
resort at the end of a bulk session.
- ModelLifecycleService.bulk_rename_session() / BulkRenameContext wire the
deferred path through rename_model (hash index built lazily on first
recipe-touching rename).
- Blocking os.rename sequence offloaded via asyncio.to_thread so one file's
HDD I/O no longer stalls the event loop (no cross-file parallelism).
- Skip logic, per-batch WebSocket progress, cancellation, and result
counters unchanged.
- Model modal hash footnote now shows Civitai model id and version id
(right-aligned, quick-copy buttons); hidden for non-Civitai models
- Hash/id exact search (sha256/autov2/autov3/civitai ids) is now always
on: the search-options "hash" toggle is removed and the search_hash
query param is silently ignored for API compatibility
- Footnote render condition relaxed so autov3-only and id-only models
still show the line
- i18n: 4 new keys translated in all 9 locales; filters.hash key removed
On a CivitAI/CivArchive 429, download-model and download-model-get now
return HTTP 429 with {"reason": "rate_limited", "retry_after": N}
instead of a generic 500 string, and the queue row goes back to
"queued" rather than history as failed — so queue drivers can
auto-pause and retry later instead of burning through the queue.
- new DownloadRateLimitError carrying retry_after/host (opt-in via
raise_on_rate_limit on Downloader; other call sites keep the legacy
string behavior)
- fail-fast pre-flight gate in DownloadManager consults
RateLimitCoordinator before acquiring the semaphore slot: hosts in
cooldown get an immediate structured 429, no HTTP request attempted
- best-effort 429 detection for the aria2 backend
Fills the 4 settings.unknownBaseModelRouting.* placeholders plus the
leftover doctor.issues.sidecar_mirror_orphans.title placeholder per the
feature owner's request, following docs/i18n-translation-guidelines.md:
option labels reuse each locale's checkpoints.modelTypes renderings,
base model follows the §5 matrix, and punctuation/register match each
file's conventions. Adds the feature's Status note and a §2 term-map
subsection for 'routing'.
The setting decides which library an unknown-model download lands in, so
it belongs next to the default-root selects rather than General >
Downloads. Element id, settings key and i18n keys are unchanged, and
loadSettingsToUI() populates it by getElementById on every modal open,
so no JS changes are needed.
Also drop "(recommended)" from the diffusion-models option label; the
default is already conveyed by pre-selection.
Cross-checked both baseModel lists against CivitAI's official
baseModelRecords (packages/civitai-shared src/basemodel.constants.ts):
- CHECKPOINT_BASE_MODELS gains SD 2.0/2.1 768, SD 2.1 Unclip, SDXL 0.9 /
1.0 LCM / Turbo / Distilled, Playground v2 and Stable Cascade
(unCLIP-style but CheckpointLoader-loaded).
- DIFFUSION_MODEL_BASE_MODELS gains SVD XT, LTXV 2.5, Flux 3 Video,
Wan Image 2.7, Wan Video 2.7 / 3.0, HiDream-O1, Boogu and the Ming
Image Design families. API-only (Kling/Sora/Veo/Imagen...), 3D and
audio baseModels are intentionally skipped.
- Pony V7 exclusion now backed by live-API evidence (model 1901521 is
AuraFlow-architecture shipping .gguf variants).
CivitAI has no model-level diffusion ModelType: DiT models are uploaded
as "Checkpoint" or "UNet", with only uploader-chosen file types to tell
them apart. model.type "unet" previously fell through type derivation
and failed with 'not supported for download'; it now goes through the
checkpoint branch in both the download manager and the download routing
endpoint, so the standard chain (file type -> baseModel lists -> unknown
default) applies.
CivitAI labels new DiT architectures (MiniMax H3, future Flux/Wan/Qwen
variants) as model.type "Checkpoint" with plain "Model" file entries,
so the DIFFUSION_MODEL_BASE_MODELS allowlist could never keep up and
such downloads were mis-routed to the checkpoint roots (e.g. model
2877206 / version 3374439). The set of true full-checkpoint families is
closed, so the baseModel fallback is inverted:
1. file type UNet/Diffusion Model -> unet (unchanged)
2. baseModel in DIFFUSION_MODEL_BASE_MODELS (now incl. MiniMax H3) -> unet
3. baseModel in new CHECKPOINT_BASE_MODELS (SD 1.x/2.x/3.x, SDXL, Pony,
Illustrious, NoobAI) -> checkpoint
4. unknown/empty baseModel -> new unknown_base_model_routing setting,
defaulting to diffusion models
The setting is exposed under Settings > Downloads, validated in
SettingsManager, and threaded into both the download manager and the
download routing endpoint so they keep agreeing.
Two gaps kept 'import example images' from working on the Other page:
- import_images/delete_custom_image/set_example_image_nsfw_level only
searched the lora/checkpoint/embedding scanners, so Other-category
models were never found ('Model with hash ... not found in cache').
All three now go through a shared scanner list that includes the
Other scanner.
- Other models (and fresh checkpoints) carry hash_status=pending with
an empty sha256, so the frontend sent an empty model_hash and the
import was rejected with 'Missing model_hash parameter'. The modal
now also sends the model's file path, and the import use case
resolves the hash on demand via the scanner's lazy-hash calculation.
The resolved hash is returned to the UI and persisted on the
showcase element so follow-up operations target the same
hash-keyed folder.
Concurrent or repeated move requests for the same model raced each other:
the first move succeeded, the rest failed with FileNotFoundError, leaving
the model card pointing at stale/empty paths.
- Serialize moves per source file with an asyncio.Lock keyed on the
normalized source path
- When the source file is already gone, reconcile instead of failing:
locate the model via the hash index or the expected target paths, repair
the metadata sidecar and cache entry, and reuse the stale cache entry
when no sidecar exists at the new location
- Avoid duplicate cache entries when the cache already tracks the moved
file; only drop the stale source entry
- Move via business paths (abspath) instead of realpath, matching every
other file mutation and the containment check; realpath stays reserved
for scanner dedup per project convention
The frontend's right-side Properties Panel falls back to WidgetLegacy for
unregistered widget types, and WidgetLegacy.draw() writes widget.width
(≈panel width) onto the real widget object. The canvas DOM overlay honors
widget.width ?? node.width, so clicking a node with the panel open squashes
the widget content to the left until undo/recreate (ComfyUI_frontend #11574).
Setting hideInPanel: true on all addDOMWidget options keeps LM widgets out
of the panel entirely. Older frontends without the option ignore it safely.
Refs #979
model_name, sampler_name and scheduler were declared as COMBO outputs, so the
documented wiring failed at queue time with "Return type mismatch between linked
nodes". ComfyUI only accepts a COMBO output into a node that declares its
dropdown as COMBO/IO.Combo, while Load Checkpoint, KSampler and the LoRA Manager
loaders expose their options as a plain list; comfy_execution.validation rejects
any non-string input type there, and a STRING output is rejected the same way.
Declare the three sockets untyped ("*"), the type ComfyUI's own Primitive node
uses to feed widget inputs. Verified with execution.validate_inputs that they now
link into both classic list dropdowns and IO.Combo inputs.
Also corrects the wiring guide, which claimed COMBO was the supported type.
Add a "Save Recipe with Workflow" action next to "Save Recipe" in the LoRA
widget context menu. It posts the current UI-format graph alongside the save
request so the stored preview embeds it and the recipe can send the graph back
to ComfyUI. Embedding stays opt-in rather than folded into "Save Recipe": the
workflow is by far the largest metadata field and its widget values may carry
sensitive data.
- web/comfyui: new menu entry; saveRecipeDirectly({ embedWorkflow }) posts the
UI graph and reports the outcome (embedded / skipped) via toasts.
- save_recipe_from_widget handler: reads an optional JSON workflow field so the
long-standing body-less POST keeps working, including from cached clients.
- RecipePersistenceService.save_recipe_from_widget: embeds the graph through
the existing optimize_image workflow path, derives has_workflow by detection,
and skips graphs above MAX_WORKFLOW_EMBED_BYTES with workflow_skipped.
CivitAI serves a re-encoded, metadata-free optimized rendition as the recipe
preview, so the ComfyUI workflow embedded in the original image was dropped:
imported recipes reported has_workflow=false and never offered "Send Workflow
to ComfyUI" even when the source image carried one.
Recover the workflow from the original rendition and carry it to the save step
as data, so the stored preview stays the small optimized image:
- ExifUtils: embed a caller-supplied workflow during optimize_image's single
encode pass, and add embed_workflow() to patch WebP EXIF in place (used by
the verbatim skip_optimize branch and as a safety net).
- RecipePersistenceService.save_recipe: embed metadata["workflow"] before
detecting has_workflow.
- analyze_remote_image: return the workflow recovered from the original
rendition it already downloads for EXIF parsing.
- RecipeManagementHandler: add _fetch_original_media() and workflow helpers;
_do_import_from_url reuses them, and _do_import_remote_recipe fetches the
original only when CivitAI reports a ComfyUI payload (meta.comfy) so
workflow-less images pay no extra bandwidth.
- Batch URL imports and the import modal forward the recovered workflow.
Verified against the reported image: has_workflow flips from false to true and
the recovered workflow matches the original (25 nodes, same graph id).
A model root that moved earlier is mirrored under a pinned name derived
from its old path. If anything resolved against the new sidecar path before
the relocation ran, the destination got a map naming the mirror after the
*current* path. migrate_root's keep-newer transfer then dropped the source
map, so the moved metadata stayed orphaned under the pinned component while
reads followed the new name and rebuilt defaults — losing favorites, notes
and tags a second time.
The identity map is now relocated by relocate_root_map() instead of the
generic transfer: entries recorded under the old sidecar root win for the
roots they describe, destination-only entries are preserved, and the cache
is dropped so the next resolution reloads the merged map. A merge that
cannot be written is reported as a migration error rather than silently
stranding the moved metadata.
Reported by the Codex review on #1131.
Centralized sidecars were addressed by a hash of the model root's absolute
path, so moving or renaming a root produced a new mirror directory. The
scanner then found no sidecar there, rebuilt default metadata, and silently
lost favorites, notes, tags and usage tips for every model under that root,
leaving the old metadata orphaned on disk.
Mirrors are now addressed by a root identity pinned in
<sidecar_root>/.lm-sidecar-roots.json. The identity starts as the existing
deterministic <basename>-<path digest> -- so pre-existing mirrors keep
resolving even if the map is lost, and a relocated sidecar root keeps its
names -- and is re-anchored to the root's new path when it moves, matched by
basename and recorded sample directories. Ambiguous matches are never
guessed: the mirror is left untouched and reported.
Also:
- drop the <library> path segment (single-library direction); a legacy
library prefix is only recognised while adopting an existing mirror, which
also keeps the unreleased centralized layout usable
- surface stranded mirrors in Doctor and in the log instead of silently
rebuilding sidecars
- keep the default alongside mode untouched: the identity map is loaded and
reconciled lazily, only while centralized storage is in use
applyTheme() mirrors the active mode onto <body> as data-theme="dark", but
the theme preset is only ever written to <html>. The palette token blocks in
tokens/colors.css and base.css used the bare attribute selector
[data-theme="dark"], so <body> matched them on its own and re-declared the
default dark palette (#1a1a1a / #2d2d2d / ...) directly on the body, where it
shadowed the preset values inherited from <html>. Every non-default preset
therefore painted the selected accent over the default preset's background,
surface, text and border tokens, and flipped into that state ~200ms after
load, when initTheme() first touched <body> — the accent-tinted background
flash seen on reload and nav-tab switches. Reached only in dark mode, since
light mode has no [data-theme="light"] token block.
Scope the palette token blocks to :root so a data-theme attribute on any
descendant (only <body> has one) can no longer re-declare them; descendant
rules such as [data-theme="dark"] .foo still match through <html>. Add a
regression guard that fails on bare attribute token blocks.
set_hf_url rejected files under config.other_roots (VAEs, text encoders,
upscalers, ...) with 'File is not within any configured model directory'
because neither _find_matching_root nor _infer_model_type knew about the
Other category. Include other_roots in both, so linking routes the cache
update to the Other scanner instead of falling back to the LoRA one, and
downloads into Other roots keep the lazy-hash metadata path.
Also make the root prefix match boundary-aware so /models/vae no longer
swallows a sibling like /models/vae-old.
Fill the [TODO: Translate] placeholders for the 7 settings open*/path
keys, the confirm-dialog destination line and the 13-key migration
summary block in all 9 locales, per the sidecar storage terminology in
docs/i18n-translation-guidelines.md (new term rows added there for
storage location / Open Folder / installation folder).
Follow the app's existing operation-summary convention (Metadata Fetch
Summary / Batch Download Summary): a self-managed modal appended to
document.body with a 3-state summary header, stat cards (moved /
models / skipped / conflicts / errors), and a failure table listing
per-model errors that were previously swallowed into a single count.
The storage location line and Open Folder action move into the modal
actions; the page reload still happens only when the modal is
dismissed. ESC is captured so it never reaches the settings modal
underneath. The obsolete migrateSuccess toast key is dropped — the
modal is the success feedback now.
Addresses PR review: on a native Linux/SSH session with neither DISPLAY
nor WAYLAND_DISPLAY (and not Docker/WSL), _open_path unconditionally
launched xdg-open and reported success even though no file manager can
open. Mirror open_settings_location: hand the path to the browser for
copying instead. Fixes open_backup_location, open_wildcards_location
and open_sidecar_location together.
After migrating to centralized sidecar storage users had no indication
where their files went, and portable-mode installs silently placed the
sidecar root inside the plugin folder where a reinstall or git clean
would delete it.
- Migration now also covers models excluded from the library view and
returns the resolved sidecar root in its result payload
- get_settings exposes the resolved sidecar root, whether it is the
default, and whether it lives inside the installation folder
- New POST /api/lm/sidecars/open-location endpoint opens (or copies)
the sidecar storage folder
- Settings UI always shows the effective storage path with an
open-folder button, and warns when the root is inside the
installation folder (portable-mode hazard)
- Migration confirmation shows the destination; on completion a result
dialog summarizes moved/skipped/conflict counts with the storage
location and an open-folder action
- Ignore /sidecars/ at the repository root so portable-mode sidecars
are never committed
Refs #1045
Fills in the 23 keys that came with optional centralized sidecar storage
(settings.sections.sidecarStorage, the 18 settings.sidecarStorage.*
labels/help/status/confirm strings and the 4
modals.sidecarMigrationConfirm.* titles/button), which the feature PR merged
as [TODO: Translate] copies. No placeholder remains in any locale.
Each locale reuses its own storage-relocation verb rather than a
transliteration of "migration" (ja 移動, ko 이동, ru перенос, matching
settings.folderSettings.recipesPathMigrating), its existing "preview images"
and nav-path renderings, and quotes the migrate button label the way its
other UI-label references do. `.metadata.json`, `.civitai.info` and the
`(<settings dir>/sidecars)` literal stay verbatim.
docs/i18n-translation-guidelines.md gains the "Sidecar storage feature" term
table and pass note, and refreshes the leaf-key count to 2128.