Compare commits

...

34 Commits

Author SHA1 Message Date
Will Miao 6d3f82976f fix(scanner): serve folder tree from scan-recorded, persisted directory list (#1110)
The include_empty folder tree (download/move modals) walked every model
root synchronously on the event loop via get_all_folders(). On network
(NAS) roots this froze the whole server for the duration of the walk —
blocking WebSocket progress, aria2 RPC and the download queue — and the
5s TTL re-triggered the walk on nearly every modal interaction.

The scanners already visit every directory during cache scans, so record
the full directory list (including empty folders) there instead:

- _gather_model_data/_reconcile_cache collect directories during the
  existing walks; reconcile refreshes and persists the list even when no
  model files changed.
- ModelCache gains an all_folders field (None = never recorded).
- PersistentModelCache stores the list in a new folders table, with a
  cache_meta flag distinguishing 'recorded empty' from legacy snapshots.
- get_all_folders() is now a pure in-memory read. A legacy snapshot
  triggers a one-shot backfill walk in a worker thread (never on the
  event loop) that records and persists the list.
- Moves add the destination folder (and parents) incrementally instead
  of invalidating a TTL cache.
2026-09-11 23:03:24 +08:00
Will Miao 91b2735dad fix(recipes): make batch-import directory browser work on Windows (#1106)
The browse endpoint and its frontend were written with POSIX-only
assumptions, so on Windows pressing Browse immediately failed with
"Access denied to this directory":

- The frontend opened the browser at "/", which resolves to the
  current drive root on Windows.
- The allowlist check used Path("/"), which has no drive letter on
  Windows, so relative_to() rejected every drive-qualified path —
  anything outside the user profile was denied.

Fixes:
- Empty browse path now defaults to the user home directory instead of
  erroring; the frontend sends "" rather than the POSIX-only "/".
- The access check is platform-aware (drive-qualified on Windows,
  absolute on POSIX).
- Parent navigation uses the server-provided parent_path; the root
  check is now path.parent == path (the old str/anchor comparison
  self-looped at Windows drive roots).
- Browsing up from a Windows drive root shows a virtual list of
  available drives so users can switch drives without typing a path.
2026-09-11 22:23:55 +08:00
Will Miao 3112869a21 docs(technical): record Windows case-fold fallback follow-up in reconcile
The Windows-only case-insensitive match in ModelScanner._reconcile_cache
is the only pass left unverified by the recent realpath cleanup: realpath
may already cover case differences on Windows, and if the branch is ever
reachable it is O(files x cache entries). Records the reachability
question, the verification steps for a Windows run, and the two possible
fixes.
2026-09-11 22:23:55 +08:00
Will Miao aa630bf85b perf(services): skip per-file realpath work in cache reconciliation
A no-change Refresh still computed os.path.realpath for every model file
in the library and for every cached entry. Both values are only ever
consulted when a discovered file is missing from the cache, so on a
50k-file library they cost ~1.3s and ~0.6s while being used zero times.

- Compute the per-file realpath only after the exact cache match fails
- Build the physical-path alias map lazily on the first miss; the
  cross-run alias guard (overlapping roots / symlink layout changes)
  still keeps the cached entry instead of a delete + re-add, which would
  re-read metadata and re-hash the whole library
- Snapshot get_model_roots() once for the new-file pass instead of
  re-reading it for every added file
- Run the duplicate-path integrity pass only when the snapshot already
  contained duplicates or files were appended; a clean, unchanged cache
  has nothing to clean. Duplicates can only be introduced by external
  code rewriting raw_data or by this pass's own appends.

Zero-change reconcile drops from ~1400ms to ~120ms on 50k files, and an
alias flip still re-processes 0 files (#1108 investigation).
2026-09-11 22:23:55 +08:00
Will Miao e0052cd237 fix(download): align location-step root selection with backend diffusion routing
The download modal's location step decided between checkpoint and unet
roots using only the CivitAI file-type signal, while the backend also
falls back to DIFFUSION_MODEL_BASE_MODELS. Models like Anima (file type
"Model") were offered checkpoint roots in the UI even though
use_default_paths would route them to the unet root.

- Extract the two-tier decision into py/services/download_routing.py and
  reuse it in DownloadManager._execute_download
- Add POST /api/lm/download/routing so the UI asks the backend for the
  routing decision; fall back to the local file-type check on failure
- ModelVersionsTab: search both checkpoint and unet roots when resolving
  an existing version's download path
2026-09-11 12:41:03 +08:00
Will Miao 3cdc5ba7a2 fix(download): stop aria2 from leaking transfers when a download is cancelled
A cancel landing between aria2.addUri acceptance and the _transfers
registration found no tracked transfer, so DownloadManager tolerated the
"not found" and only cancelled the asyncio task — the daemon kept
downloading the file untracked while history showed the download as
cancelled.

- Register the gid in _transfers immediately after addUri returns,
  before any further await (state-store persist moved after it)
- Shield the addUri RPC so a mid-flight cancellation still learns the
  accepted gid and forceRemoves it before re-raising CancelledError
- On cancellation during the state persist, remove the daemon transfer
  unless it is paused (skip_download relies on paused gids surviving)
2026-09-11 08:14:14 +08:00
Will Miao 04485e384f feat(checkpoints): add Enrich HF Metadata (AI) to card context menu
The option only existed in the LoRA page menu. Move updateEnrichMenuItem
and enrichWithAgent into ModelContextMenuMixin so both pages share the
implementation, and add the menu item to the checkpoints template.
2026-09-09 17:30:42 +08:00
Will Miao a03dc4002f fix(move): recalculate sub_type when moving models across roots
Moving a checkpoint into a unet root (or vice versa) moved the file and
updated the in-memory cache, but three stale spots survived until a
manual cache rebuild:

- The moved .metadata.json kept the old sub_type, and the opportunistic
  sync_cache_from_metadata path (fired by get_model_metadata and example
  image metadata updates) trusted it, reverting the cache entry and the
  SQLite snapshot to the pre-move sub_type. Loader nodes filter strictly
  on sub_type, so the model stayed listed under the old type.
- The manager page discarded the move response's cache_entry, so the
  card badge (CKPT/DM) and context menu label kept showing the old type.

Fixes:
- move_model now re-resolves sub_type from the target location (new
  resolve_sub_type_for_path hook) and persists it into the moved
  .metadata.json.
- _sync_cache_from_metadata_impl runs desired entries through
  adjust_cached_entry so location-derived fields cannot be re-poisoned
  by stale metadata snapshots.
- MoveManager carries cache_entry.sub_type into the in-place card
  update so badge and context menu reflect the new type immediately.
2026-09-09 17:28:41 +08:00
Will Miao cc9d3bff42 fix(download): accept HuggingFace blob URLs in download dialog
detectUrlType only matched /resolve/ links, so pasting a HF web preview
(/blob/) URL fell through to direct-http and surfaced a misleading
'Invalid CivitAI URL format' error. Treat blob URLs as hf-resolve.
2026-09-09 11:47:17 +08:00
Will Miao 2672b3331b i18n: translate rematch summary modal strings into 9 locales 2026-09-09 11:07:42 +08:00
Will Miao 4963bf2b2e feat(recipes): show a summary modal after rematch runs
Replace the post-run toast cascade and the standalone L4 results modal
with a summary modal modeled on the batch download summary: 3-state
header, stat cards (matched / needs review / unresolved / errors),
an L4 review table with per-entry undo, and a copyable report. Wired
into the global, bulk and single-recipe rematch entries; complete
no-op runs keep the lightweight toast. Obsolete results-modal code,
styles and i18n keys are removed.
2026-09-09 10:38:10 +08:00
Will Miao 51cad6f852 i18n: translate recipe rematch options/results strings into 9 locales 2026-09-09 07:05:25 +08:00
Will Miao 1b5cbbbaa0 feat(recipes): add reconnect remediation paths for missing recipe LoRAs
- Snapshot pre-rematch entry state (reconnectSnapshot) so rematched
  entries can be undone via the existing restore flow
- Bulk missing-LoRA downloads mark unresolvable failures hash-invalid,
  flipping those entries from download to reconnect candidacy
- Recipe modal always offers a reconnect action next to download for
  missing LoRA entries
- Rematch runs collect an opt-in relaxed-matching choice (also reconnect
  missing models by file name) via a pre-run options dialog on the
  global, bulk and single-recipe entries
- L4 (filename-level) matches are listed in a results dialog with
  per-entry undo
2026-09-09 06:59:54 +08:00
Will Miao e747946f7a fix(onboarding): keep folder sidebar fixed-positioned during tutorial highlight
The .onboarding-target-highlight class sets position: relative, which
overrode .folder-sidebar's position: fixed (equal specificity, later
stylesheet). The sidebar left fixed positioning and moved in-flow, while
the spotlight/mask cutout stayed at the pre-highlight rect, leaving an
empty highlighted region during the folder sidebar step.
2026-09-07 19:32:58 +08:00
Will Miao 53fa22f39c fix(lora-loader): preserve repeated spaces inside lora names
Whitespace cleanup in cleanupLoraSyntax() and the autocomplete blur
formatter collapsed all whitespace runs, including inside <lora:...>
tags. A file named 'test -  0021.safetensors' was rewritten to
'test - 0021' in the node text, so runtime file resolution failed.

Protect lora tags with placeholders (or segment splitting) so only
whitespace between entries is normalized; names inside tags are kept
byte-for-byte.
2026-09-07 19:20:15 +08:00
Will Miao 82b34097fb refactor(metadata): remove vestigial top-level trainedWords field
The field dates back to a development-stage bug in the enrich-metadata
(agent) pipeline, which briefly wrote trigger words at the top level of
model metadata instead of the established civitai.trainedWords location.
The write path was fixed before the feature merged to main (PR #1013)
and never shipped in any release, so no writer has existed since.

Remove the leftover pieces:

- BaseModelMetadata.trainedWords field (py/utils/models.py); sidecars
  from that dev window now pass the key through _unknown_fields instead
- HF download handler's strip-empty-trainedWords special case, reverting
  to saving the metadata object directly (py/routes/handlers/hf_handlers.py)
- trainedWords in the LLM enrichment context (agent_service.py)
- matching fallbacks/fixtures in the enrich_hf_validation harness and
  post-processor test

Trigger words continue to live in civitai.trainedWords for all model
sources, which is what the UI, agent post-processor, and metadata sync
all read and write.
2026-09-07 16:24:15 +08:00
Will Miao a7995db009 fix(llm): add failure cooldown and lock for model catalog fetch 2026-09-07 10:17:48 +08:00
Will Miao 5ae4aef30e fix(llm): disable brotli for catalog fetch to prevent native crash (#1099, #1101)
models.dev is served by Cloudflare with brotli compression when the
client advertises it, and brotli is a required dependency here, so
aiohttp always negotiates br. A corrupted br stream can crash the
native decoder with a Windows access violation (a Python-level
exception handler cannot catch it), or produce garbage bytes.

Send an explicit "Accept-Encoding: gzip, deflate" header on the model
catalog and Ollama model-list requests so the server never returns
brotli. zlib handles corrupt gzip data by raising ContentEncodingError
(an aiohttp.ClientError subclass), which the existing handlers already
catch and degrade to a warning with an empty-catalog fallback.
2026-09-07 09:54:28 +08:00
willmiao 08023f0cd9 docs: auto-update supporters list in README 2026-09-06 14:29:39 +00:00
Will Miao 6e2185c182 chore(release): bump version to v1.2.2 2026-09-06 22:29:26 +08:00
Will Miao 41302e75ba fix(download): save multi-variant files under raw stored filenames (#1100)
The public REST API rewrites files[].name to "{model}_{version}" for
non-LoRA model types, so every precision variant of a multi-file version
shared one name and landed on disk with a random short-hash suffix.

Fetch the raw stored filename from the model-versions/mini endpoint
(always pinned with modelFileId) and use it for the on-disk name and
metadata when available; fall back silently to the REST name otherwise.
CivArchive already serves raw names and is skipped.
2026-09-06 22:23:48 +08:00
Will Miao a17399d667 feat(recipes): delegate CivitAI-image re-import to companion browser extension
Recipes imported from CivitAI image URLs can contain 0 LoRAs: the backend
only sees the REST image API + EXIF, while the complete generation data
lives in the image page's internal trpc payload (see
docs/recipe-civitai-image-no-metadata.md). When the companion
lm-civitai-extension is installed with a valid license, re-import (single
and bulk) of CivitAI-image-sourced recipes is now delegated to the
extension via DOM CustomEvents; the extension scrapes the image page with
the user's session and calls back into the reimport endpoint with the
full metadata payload. Without the extension (or with an invalid license)
the native path runs unchanged.

- POST /api/lm/recipe/{id}/reimport accepts optional payload params
  (image_url/name/resources/gen_params/base_model/tags); the payload path
  reuses the import-remote engine with reimport semantics (user-edit
  carryover, delete-after-save), and malformed/failed payloads fall back
  to the legacy URL import. Response gains loras_count.
- The endpoint also accepts GET: the extension is GET-only by convention
  (documented in AGENTS.md).
- New static/js/utils/extensionReimportBridge.js (probeExtension /
  delegateReimport / getCivitaiImageInfo) wired into RecipeContextMenu
  and BulkManager with silent native fallback.
- i18n: toast.recipes.reimportingViaExtension added and translated in
  all 9 locales.
2026-09-06 20:26:14 +08:00
Will Miao e2d85a0a21 fix(recipes): allow download for version-only recipe LoRAs (no modelId/hash)
Page-imported recipes can carry an exact CivitAI modelVersionId but no
modelId and no hash (CivitAI exposes no sha256 for e.g. Krea versions).
canDownloadLora() required (modelId && versionId) or a hash, so such
entries were misclassified as unrepairable and offered Reconnect instead
of Download.

- canDownloadLora: treat a bare version id as downloadable (it uniquely
  pins the file; the model id is resolved on demand at download time).
  A model id without an exact version id stays non-downloadable to avoid
  silently grabbing the latest version.
- resolveLoraDownloadIdentifiers: when a hash is absent but a version id
  exists, resolve the owning model id via /civitai/model/version/{id}
  (same endpoint the bulk download missing flow uses). Hash-only and
  direct (modelId+versionId) paths are unchanged.
2026-09-06 19:05:07 +08:00
Will Miao 303833bbae fix(llm): catch UnicodeDecodeError when fetching model catalog (#1099)
resp.json() raises UnicodeDecodeError (not JSONDecodeError) when the
remote body contains invalid UTF-8 bytes, which the exception handler
did not catch and could crash the app. Apply the same fix to both
_load_model_catalog and fetch_ollama_models so they fall back to an
empty catalog. Add regression tests for both paths.
2026-09-06 12:04:16 +08:00
Will Miao f86b7b55d6 feat(recipes): remove deprecated Repair Metadata feature
The recipe "Repair Metadata" action has been marked Deprecated in the UI
for a while and cannot reliably recover recipes imported from CivitAI URLs
whose REST meta has no resources/hashes and whose image has no embedded
metadata (e.g. CivitAI-only generation data). Drop the feature end to end.

Backend:
- remove repair routes (repair, cancel-repair, recipe/{id}/repair,
  repair-bulk, repair-progress) and their handler mappings/methods
- remove RecipeScanner repair_all_recipes / repair_recipe_by_id /
  _repair_single_recipe and REPAIR_VERSION
- remove WebSocketManager recipe-repair progress channel
- drop repair_version column from the persistent recipe cache
- rematch mutual-exclusion now only checks rematch

Frontend:
- remove repair entries from per-recipe, bulk and global context menus
- remove repairRecipe / repairSelectedRecipes / repairRecipes + cancelRepair
  and the repairBulk API client method/endpoint
- drop recipe-repair i18n keys (synced across locales; doctor keys kept)

Tests/docs: delete test_recipe_repair.py, update scaffolding/routes/ws/
persistent-cache/integration tests and i18n guideline examples.
2026-09-05 16:56:20 +08:00
Will Miao 782bb53784 fix(ui): restore equal-width toasts in toast container
Commit 86aa1d80 added align-items: flex-end to .toast-container and
dropped the .toast min-width to 200px. With flex-end alignment each
toast now shrinks to its own content width, so toasts of different
message lengths render at inconsistent widths. Drop the align-items
override so the container falls back to stretch, giving every toast a
single shared width as before the change.
2026-09-05 07:23:54 +08:00
Will Miao 139231e225 chore(skill): remove lora-manager-e2e skill, keep sandbox helpers in scripts/e2e/ 2026-09-05 07:10:43 +08:00
Will Miao 121d8d5cea fix(ui): lighten toolbar shortcut keycaps and fix contrast on active buttons 2026-09-05 00:21:11 +08:00
Will Miao ec147bd677 feat(ui): add setting to keep the action bar visible while scrolling (#1095)
Adds a 'Keep Action Bar Visible' toggle (default off) under
Settings > Interface > Layout Settings. When enabled, the controls bar
(Refresh, Download, etc.) and the breadcrumb nav are wrapped in a shared
sticky container (.sticky-topbar) so both stay pinned as one unit; when
disabled, the wrapper is display: contents and the original behavior
(only the breadcrumb stays visible) is preserved.
2026-09-04 23:53:27 +08:00
Will Miao 93fc28b499 chore(ui): rebuild vue-widgets bundle
Rebuilt from the preceding three commits' sources:

- active-filters chip and its dead settings-toggled broadcast removed
  (the built bundle also no longer embeds the scripts/app.js test shim
  that the chip's settings.js import used to pull in)
- scrollbar inset re-measured on programmatic value changes
- app/api bindings canonicalized to "../../../scripts/*" externals and
  settings.js no longer inlined (bound at runtime via "../settings.js"
  to the vanilla module instance), per the new build guard
2026-09-04 19:02:36 +08:00
Will Miao 7afed1a14b fix(ui): cross-link right-click menu in active-filters settings tooltip
The loramanager.lora_active_filters_autocomplete tooltip only mentioned
the /activefilters and /noactivefilters commands, while the prompt-node
tag-autocomplete tooltip cross-links every toggle entry point (typing in
the node and the node's right-click menu). 6ba64ebb added the right-click
menu entry without extending the tooltip to match; align the wording with
the established pattern.
2026-09-04 19:02:31 +08:00
Will Miao e6f5142e48 fix(ui): re-measure autocomplete scrollbar inset on programmatic value changes
The --lm-vscrollbar-width inset from 634ea7f2 was only refreshed on input
events, mount and mode changes. Programmatic value updates (widget.setValue
from "send lora to workflow", external value-change events) change the
textarea content without an input event, leaving the corner clear (x)
button overlapping a freshly appeared classic scrollbar until the next
keystroke. Mount-time pending value replay was already covered.

- onExternalValueChange and widget.onSetValue now call
  updateVScrollbarWidth() alongside the hasText update
- tests: cover both paths by overriding textarea metrics to an overflowing
  state and asserting the 15px gutter lands in the CSS var
2026-09-04 19:02:31 +08:00
Will Miao 87f05fb66c build(vue-widgets): keep shared runtime modules external in the widget bundle
Guard against the inlined-shim bug class that broke the removed
active-filters chip: importing web/comfyui/* modules from widget source
inlines them into lora-manager-widgets.js, and their own relative imports
then resolve against the repo filesystem at build time instead of the
vanilla files' runtime URL layout.

A resolveId plugin (enforce: pre) now returns explicit external markers:

- scripts/app.js and scripts/api.js imported at any "../../scripts/*"
  depth are rewritten to the canonical "../../../scripts/*" specifier so
  every app/api binding in the bundle is the real ComfyUI module. The
  repo-root scripts/app.js is a unit-test shim (in-memory settings store)
  and must never be bundled; the canonical depth is the only one that
  resolves from the emitted bundle's served location.
- web/comfyui/settings.js is externalized to "../settings.js" so the
  bundle binds to the SAME vanilla module instance the ComfyUI extension
  loader already runs - real settings store, registerExtension side
  effect executed exactly once, no duplicated module state.

A companion plugin warns on any web/comfyui/* import from widget source,
since an inlined copy still duplicates module-level side effects.

Notes from validating the mechanism: rollup output.paths resolves
returned paths to absolute filesystem locations (rejected), and a depth
regex inside rollupOptions.external matches raw specifiers before
resolveId hooks run and would emit the shim-relative depth verbatim
(rejected) - hence explicit { id, external: true } returns.
2026-09-04 19:02:25 +08:00
Will Miao cf64e5baa8 fix(ui): remove per-node active-filters chip from loras widgets
The indicator chip added for /activefilters discoverability was broken by
design of its import path: AutocompleteTextWidget.vue imported
web/comfyui/settings.js into the vue-widgets bundle, and settings.js's
"../../scripts/app.js" import resolved at build time to the repo-root test
shim (scripts/app.js, an in-memory settings store). The chip therefore read
and wrote an orphaned in-memory Map: clicking it flipped only its own
visual state and never touched the real ComfyUI setting that
autocomplete.js consults (use_active_filters query param).

Beyond the defect, a persistent per-node control for a global persisted
setting misleads users and needs cross-instance sync machinery, which the
footer hint, slash commands, right-click menu entry and settings dialog
already cover.

- AutocompleteTextWidget.vue: remove the chip button, its state/handlers,
  the settings.js import (the shim-inlining pathway) and all chip styles
- AutocompleteTextWidget.test.ts: drop the chip indicator describe block
  and the settings.js module mock; beforeEach import no longer needed
- settings.js: drop the lora-manager:setting-toggled window broadcast and
  its export — the chip was its only consumer, so every
  setLoraManagerSettingValue write no longer dispatches a dead event
- autocomplete.activeFilters.test.js: drop the broadcast assertion test
- loraLoader.activeFiltersMenu.test.js: drop SETTING_TOGGLED_EVENT_NAME
  from the settings.js mock

Discoverability of /activefilters // /noactivefilters is unchanged:
command-list footer, first-run hint, node context menu, settings dialog.
2026-09-04 19:02:16 +08:00
127 changed files with 7462 additions and 3943 deletions
-146
View File
@@ -1,146 +0,0 @@
---
name: lora-manager-e2e
description: "End-to-end testing and validation for LoRa Manager features. Use ONLY for sandboxed E2E validation of LoRa Manager standalone mode: start the standalone server on a free port with --settings-path, drive the web UI (http://127.0.0.1:{PORT}/loras) via Chrome DevTools MCP, and verify frontend-to-backend integration. NOT for UI behavior checks that unit tests (Vitest/jsdom) can cover. Trigger keywords: E2E, standalone, Chrome DevTools MCP, lora-manager-e2e, sandbox."
---
# LoRa Manager E2E Testing
End-to-end testing of LoRa Manager standalone mode using Chrome DevTools MCP.
## When to Use — and When NOT To
E2E runs are slow and token-heavy. Reach for them only when the question genuinely
spans server + browser (routing, scan persistence, websocket updates, EXIF writes).
- **Default to unit/component tests first**: `npm run test:js` (Vitest/jsdom) covers
DOM rendering, modal behavior, event handling and API-client calls deterministically
in seconds. Backend logic goes through `pytest`. A UI-behavior question answered by
jsdom MUST NOT be escalated to E2E.
- **Use E2E only when** the behavior cannot be observed without a live server and a
real browser, e.g. template rendering through the aiohttp server, scanner → SQLite
persistence → API → DOM round-trips, or real EXIF/image writes.
- If you start an E2E and realize a unit test would answer the question, stop and
switch.
**Browser driver is fixed: Chrome DevTools MCP.** Do not substitute kimi-webbridge —
it operates on the user's real browser (real tabs, real sessions, synthetic
`isTrusted=false` events), which breaks the isolation this skill requires and lacks
the console/network inspection E2E debugging relies on. kimi-webbridge is for
interactive browsing with the user's real login sessions, not for sandboxed E2E.
## Conventions
- **`{PORT}`**: default candidate `8188`, but it is **commonly occupied by a live
ComfyUI** — always check first (`ss -tlnp | grep ':{PORT}'`) and use a free port
(e.g. `8199`). Substitute the chosen port everywhere below. Never kill a process
you did not start for this E2E.
- **`<repo-root>`**: the repository/worktree root; run all commands from there.
- **`<sandbox>`**: a throwaway dir, e.g. `/tmp/opencode/<plan>-e2e`.
## SANDBOX (MANDATORY)
> Every E2E run MUST target a throwaway sandbox, never real user data.
1. **Explicit settings directory**: always launch with `--settings-path <sandbox>/settings`.
This pins ALL runtime data (`settings.json`, `cache/`, `backups/`, `logs/`, `stats/`,
`wildcards/`) under the sandbox. **Never** create `<repo-root>/settings.json` — the repo
folder is usually the real ComfyUI plugin folder and a portable settings file there is
read by the real instance.
2. **Sandboxed library paths**: point `folder_paths` / `recipes_path` /
`example_images_path` at disposable dirs under `<sandbox>` — never the real library,
real recipe dir, or real settings:
```json
{
"folder_paths": {
"loras": ["<sandbox>/models/loras"],
"checkpoints": ["<sandbox>/models/checkpoints"],
"unet": ["<sandbox>/models/checkpoints"],
"diffusers": []
},
"recipes_path": "<sandbox>/recipes",
"example_images_path": "<sandbox>/example_images"
}
```
3. **Real-data protection proof**: before starting and after finishing, snapshot the real
config and recipe library and confirm they are byte-identical; also confirm
`<repo-root>` gained no `settings.json` or `cache/`:
```bash
sha256sum ~/.config/ComfyUI-LoRA-Manager/settings.json > <sandbox>/settings.before.sha256
ls ~/models/recipes/*.recipe.json 2>/dev/null | wc -l > <sandbox>/recipes-count.before.txt
# AFTER the run: record again and diff. Any change = the run leaked into real data.
```
## Quick Start
```bash
cd <repo-root>
# 1. Sandbox
mkdir -p <sandbox>/settings <sandbox>/models/{loras,checkpoints} <sandbox>/{recipes,example_images}
# write <sandbox>/settings/settings.json per the SANDBOX example
# 2. Port
ss -tlnp | grep ':{PORT}' || echo "port {PORT} is free"
# 3. Server — MUST be fully detached (a plain background & dies with the shell);
# the helper enforces this and manages its own pidfile
python .agents/skills/lora-manager-e2e/scripts/start_server.py \
--port {PORT} --settings-path <sandbox>/settings --wait --timeout 30 --detach
ss -tlnp | grep ':{PORT}' # verify listening BEFORE proceeding
# 4. Chrome with remote debugging, then connect Chrome DevTools MCP (verify via list_pages)
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:{PORT}/loras
```
Then drive the UI with the MCP tools (`take_snapshot`, `click`, `fill`, `fill_form`,
`evaluate_script`, `wait_for`, `list_network_requests`, `list_console_messages`) —
see [references/mcp-cheatsheet.md](references/mcp-cheatsheet.md) for patterns.
Server restart after config/fixture changes:
```bash
python .agents/skills/lora-manager-e2e/scripts/start_server.py \
--port {PORT} --settings-path <sandbox>/settings --restart --wait --detach
# then reload the browser page (ignoreCache=True)
```
`--restart` only kills the E2E server the script itself started (via its pidfile) and
aborts instead of killing unrelated processes on the port.
## Abort Rule
A sandboxed E2E should finish in well under 30 minutes. If any phase exceeds ~2x its
expected duration (server readiness > 60 s, MCP connect > 2 min, a single scenario >
10 min), or any single tool call fails 3+ times in a row, **STOP** — do not retry
blindly. Report `BLOCKED` with the phase, last observed state (server PID,
`ss -tlnp` output, page snapshot, last API response) and suspected cause. A clean
BLOCKED report beats an hour of retries.
## Troubleshooting
- **"browser is already running" / `list_pages` fails**: a stale Chrome holds the
profile dir. Find it (`ps -ef | grep -i '[c]hrome.*user-data-dir'`), confirm it is a
leftover QA Chrome (not the live ComfyUI, not your current MCP browser), kill only
that PID, then retry `list_pages`.
- **MCP refuses to write screenshots into the worktree**: save to `/tmp` via
`take_screenshot(filePath="/tmp/...")` and copy into the evidence dir from the shell.
## Cleanup
1. Stop the standalone server: `kill <recorded-pid>` (only the PID you started), then
confirm `ss -tlnp | grep ':{PORT}'` is empty.
2. Close browser pages (keep at least one open).
3. `rm -rf <sandbox>`; verify `<repo-root>` gained no `settings.json` or `cache/`.
4. Re-run the real-data protection check from the SANDBOX section and record the result.
## References & Scripts
- [references/mcp-cheatsheet.md](references/mcp-cheatsheet.md) — Chrome DevTools MCP
command patterns (navigation, waiting, snapshots, forms, network, console, performance).
- [references/test-scenarios.md](references/test-scenarios.md) — detailed test scenarios
(list display, metadata editing, recipes, settings, import/export).
- [references/recipe-rematch-fixtures.md](references/recipe-rematch-fixtures.md) —
fixture format, fresh-state reset and known gaps for recipe rematch/repair E2E runs.
- `scripts/start_server.py` — start/restart the standalone server
(`--port --settings-path --restart --wait --timeout --detach`); refuses to touch
unrelated processes on the port.
- `scripts/wait_for_server.py` — poll readiness (`--port --timeout`).
@@ -1,360 +0,0 @@
# Chrome DevTools MCP Cheatsheet for LoRa Manager
Quick reference for common MCP commands used in LoRa Manager E2E testing.
> **Port convention**: `{PORT}` is the port chosen for the E2E run (default candidate `8188`, but only if actually free — see the SKILL.md Port Selection section; use e.g. `8199` when `8188` is occupied by a live ComfyUI). Always run against the **sandboxed** standalone server, never a live instance.
## Navigation
```python
# Navigate to LoRA list page
navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
# Reload page with cache clear
navigate_page(type="reload", ignoreCache=True)
# Go back/forward
navigate_page(type="back")
navigate_page(type="forward")
```
## Waiting
```python
# Wait for text to appear
wait_for(text="LoRAs", timeout=10000)
# Wait for specific element (via evaluate_script)
evaluate_script(function="""
() => {
return new Promise((resolve) => {
const check = () => {
if (document.querySelector('.lora-card')) {
resolve(true);
} else {
setTimeout(check, 100);
}
};
check();
});
}
""")
```
## Taking Snapshots
```python
# Full page snapshot
snapshot = take_snapshot()
# Verbose snapshot (more details)
snapshot = take_snapshot(verbose=True)
# Save to file
take_snapshot(filePath="test-snapshots/page-load.json")
```
## Element Interaction
```python
# Click element
click(uid="element-uid-from-snapshot")
# Double click
click(uid="element-uid", dblClick=True)
# Fill input
fill(uid="search-input", value="test query")
# Fill multiple inputs
fill_form(elements=[
{"uid": "input-1", "value": "value 1"},
{"uid": "input-2", "value": "value 2"},
])
# Hover
hover(uid="lora-card-1")
# Upload file
upload_file(uid="file-input", filePath="/path/to/file.safetensors")
```
## Keyboard Input
```python
# Press key
press_key(key="Enter")
press_key(key="Escape")
press_key(key="Tab")
# Keyboard shortcuts
press_key(key="Control+A") # Select all
press_key(key="Control+F") # Find
```
## JavaScript Evaluation
```python
# Simple evaluation
result = evaluate_script(function="() => document.title")
# Async evaluation
result = evaluate_script(function="""
async () => {
const response = await fetch('/loras/api/list');
return await response.json();
}
""")
# Check element existence
exists = evaluate_script(function="""
() => document.querySelector('.lora-card') !== null
""")
# Get element count
count = evaluate_script(function="""
() => document.querySelectorAll('.lora-card').length
""")
```
## Network Monitoring
```python
# List all network requests
requests = list_network_requests()
# Filter by resource type
xhr_requests = list_network_requests(resourceTypes=["xhr", "fetch"])
# Get specific request details
details = get_network_request(reqid=123)
# Include preserved requests from previous navigations
all_requests = list_network_requests(includePreservedRequests=True)
```
## Console Monitoring
```python
# List all console messages
messages = list_console_messages()
# Filter by type
errors = list_console_messages(types=["error", "warn"])
# Include preserved messages
all_messages = list_console_messages(includePreservedMessages=True)
# Get specific message
details = get_console_message(msgid=1)
```
## Performance Testing
```python
# Start trace with page reload
performance_start_trace(reload=True, autoStop=False)
# Start trace without reload
performance_start_trace(reload=False, autoStop=True, filePath="trace.json.gz")
# Stop trace
results = performance_stop_trace()
# Stop and save
performance_stop_trace(filePath="trace-results.json.gz")
# Analyze specific insight
insight = performance_analyze_insight(
insightSetId="results.insightSets[0].id",
insightName="LCPBreakdown"
)
```
## Page Management
```python
# List open pages
pages = list_pages()
# Select a page
select_page(pageId=0, bringToFront=True)
# Create new page
new_page(url="http://127.0.0.1:{PORT}/loras")
# Close page (keep at least one open!)
close_page(pageId=1)
# Resize page
resize_page(width=1920, height=1080)
```
## Screenshots
```python
# Full page screenshot
take_screenshot(fullPage=True)
# Viewport screenshot
take_screenshot()
# Element screenshot
take_screenshot(uid="lora-card-1")
# Save to file
take_screenshot(filePath="screenshots/page.png", format="png")
# JPEG with quality
take_screenshot(filePath="screenshots/page.jpg", format="jpeg", quality=90)
```
## Dialog Handling
```python
# Accept dialog
handle_dialog(action="accept")
# Accept with text input
handle_dialog(action="accept", promptText="user input")
# Dismiss dialog
handle_dialog(action="dismiss")
```
## Device Emulation
```python
# Mobile viewport
emulate(viewport={"width": 375, "height": 667, "isMobile": True, "hasTouch": True})
# Tablet viewport
emulate(viewport={"width": 768, "height": 1024, "isMobile": True, "hasTouch": True})
# Desktop viewport
emulate(viewport={"width": 1920, "height": 1080})
# Network throttling
emulate(networkConditions="Slow 3G")
emulate(networkConditions="Fast 4G")
# CPU throttling
emulate(cpuThrottlingRate=4) # 4x slowdown
# Geolocation
emulate(geolocation={"latitude": 37.7749, "longitude": -122.4194})
# User agent
emulate(userAgent="Mozilla/5.0 (Custom)")
# Reset emulation
emulate(viewport=None, networkConditions="No emulation", userAgent=None)
```
## Drag and Drop
```python
# Drag element to another
drag(from_uid="draggable-item", to_uid="drop-zone")
```
## Common LoRa Manager Test Patterns
### Verify LoRA Cards Loaded
```python
navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
wait_for(text="LoRAs", timeout=10000)
# Check if cards loaded
result = evaluate_script(function="""
() => {
const cards = document.querySelectorAll('.lora-card');
return {
count: cards.length,
hasData: cards.length > 0
};
}
""")
```
### Search and Verify Results
```python
fill(uid="search-input", value="character")
press_key(key="Enter")
wait_for(timeout=2000) # Wait for debounce
# Check results
result = evaluate_script(function="""
() => {
const cards = document.querySelectorAll('.lora-card');
const names = Array.from(cards).map(c => c.dataset.name || c.textContent);
return { count: cards.length, names };
}
""")
```
### Check API Response
```python
# Trigger API call
evaluate_script(function="""
() => window.loraApiCallPromise = fetch('/loras/api/list').then(r => r.json())
""")
# Wait and get result
import time
time.sleep(1)
result = evaluate_script(function="""
async () => await window.loraApiCallPromise
""")
```
### Monitor Console for Errors
```python
# Before test: clear console (navigate reloads)
navigate_page(type="reload")
# ... perform actions ...
# Check for errors
errors = list_console_messages(types=["error"])
assert len(errors) == 0, f"Console errors: {errors}"
```
## Troubleshooting
### Stale profile lock ("browser is already running" / `list_pages` fails)
A Chrome profile held by a stale Chrome from a prior MCP session makes `list_pages`
fail with "browser is already running". Fix:
1. Find the stale Chrome that owns the profile dir (e.g. `~/.config/chrome-dev-profile`):
```bash
ps -ef | grep -i '[c]hrome.*user-data-dir'
```
2. Confirm it is a QA Chrome from a completed task (NOT the live ComfyUI server, NOT
your current MCP instance).
3. Kill ONLY that stale Chrome (`kill <stale-pid>`), then retry `list_pages`.
### Screenshot-write restrictions
The MCP may refuse to write into paths outside its configured workspace roots
(e.g. `.omo/evidence/screenshots/` under a worktree that canonicalizes to an unmapped
path). Save the screenshot to `/tmp` via the MCP, then copy it into the evidence dir:
```bash
# MCP: take_screenshot(filePath="/tmp/<plan>-e2e/recipe-b-after.png", format="png")
# Shell:
mkdir -p <repo-root>/.omo/evidence/screenshots
cp /tmp/<plan>-e2e/recipe-b-after.png <repo-root>/.omo/evidence/screenshots/
```
### Time budgets & abort rule
See SKILL.md "Time Budgets & Abort Guidance": if a phase exceeds ~2x its budget or a
tool call retries 3+ times in a row, STOP and report BLOCKED with the last observed
state (server PID + `ss -tlnp`, page snapshot, last API response). Do not loop.
@@ -1,72 +0,0 @@
# Recipe Rematch/Repair E2E — Fixtures, Fresh State, Known Gaps
Specialized guidance for recipe rematch/repair E2E runs, extracted from the SKILL.md
main flow. Read the SKILL.md SANDBOX section first — everything here assumes a
sandboxed run.
## Fixture Rules (validated by the task-8 E2E)
Seed the **sandboxed** `recipes_path` with hand-written fixture recipes:
1. **Filename constraint**: each file MUST be named `f"{id}.recipe.json"` **and** the
in-JSON `id` field MUST equal the filename. Discovery accepts any `*.recipe.json`,
but persistence resolves the path via `get_recipe_json_path` and
`_save_recipe_persistently` returns `False` on a mismatch → the fixture would be
counted as an error.
- `recipe-a.recipe.json` → in-JSON `"id": "recipe-a"`
2. **File format**: mirror an existing recipe JSON — top-level `id`, `file_path`,
`title`, `loras`, `fingerprint`, `gen_params`; lora entries per the persistence
conventions (`hash`, `file_name`, `modelVersionId`, `isDeleted`, ...).
3. **Companion image**: each recipe needs an image (e.g. a `.webp` generated with PIL)
referenced by `file_path`, used for EXIF verification
(`ExifUtils.append_recipe_metadata` writes a `"Recipe metadata: ..."` marker; a
freshly generated `.webp` with no marker is the clean "untouched" control).
4. **autov3 three-state contract**: for L3 (autov3-only, renamed-file) fixtures the
local model's `.metadata.json` sidecar MUST have the `autov3` key **ABSENT** (the
"unchecked" state), NOT `""``""` is the TERMINAL "checked but unavailable" state
that L3 deliberately skips. The scanner computes + persists `autov3` from the file
header during the normal library scan (`model_scanner.py` `_process_model_file`), so
the live L3 match resolves through the local autov3/hash cache; the
computed-autov3 branch for unchecked items is covered by the unit suite.
5. **Fixture design for a rematch run** (mirrors the task-8 E2E):
- `recipe-a`: lora entry `isDeleted=True`, `hash` = 12-char autov3 computed from the
local model (`calculate_autov3`, `py/utils/file_utils.py`), whose local model file
was RENAMED after the recipe was written so `file_name` differs (proves L3 match
without filename).
- `recipe-b`: parser-convention checkpoint entry (uses `id`, no `modelVersionId`)
matching a local checkpoint via L2 — the local checkpoint's `.metadata.json` MUST
carry civitai version data with that `id` so `version_index` contains it (L2
cannot match otherwise).
- `recipe-c`: healthy recipe (no deleted entries) → must remain untouched.
The scanner computes and persists model hashes during the library scan, so the sandbox
model dirs just need the model files + `.metadata.json` sidecars. With
`--settings-path`, all derived data lands under the sandbox settings dir (`cache/`,
`backups/`, `logs/`, `stats/`, `wildcards/`), and NO `cache/` appears in the repo root.
## Fresh State Between Entry-Point Runs
Each entry point (global / per-recipe / selection-bulk) must start from the same
deleted state. Between runs (keep a pristine copy in `<sandbox>/recipes-before/`):
```bash
# 1. Reset fixtures to the before-state snapshot
cp <sandbox>/recipes-before/*.recipe.json <sandbox>/recipes/
# 2. Clear the recipe/FTS caches (with --settings-path these live under the sandbox
# settings dir, NOT <repo-root>/cache)
rm -f <sandbox>/settings/cache/recipe/*.sqlite
rm -rf <sandbox>/settings/cache/fts/*
# 3. Restart the server (fresh process, fresh scan)
python .agents/skills/lora-manager-e2e/scripts/start_server.py \
--port {PORT} --settings-path <sandbox>/settings --restart --wait --timeout 30 --detach
# 4. Re-verify the server is listening + reload the browser page
```
## Cancellation Testing (KNOWN GAP)
Testing the rematch-cancel path E2E requires a run long enough to cancel mid-flight. A
tiny 3-recipe fixture set completes in **seconds** — too fast to reliably cancel. The
cancel path is currently **unit-covered only** (`rematch_all_recipes` cancellation
tests); do not block an E2E run on cancel-path verification. If you must attempt it,
you would need an artificially large/deferred fixture set to create a cancellable
window — treat this as a research task, not part of the standard E2E.
@@ -1,280 +0,0 @@
# LoRa Manager E2E Test Scenarios
This document provides detailed test scenarios for end-to-end validation of LoRa Manager features.
> **Run preconditions (from SKILL.md)**: every run uses the **sandboxed** standalone
> server on a free port `{PORT}` (default candidate `8188`, only if actually free — pick
> e.g. `8199` when `8188` is occupied by a live ComfyUI). Fixtures live in the sandboxed
> `recipes_path` as `f"{id}.recipe.json"` files with matching in-JSON `id`; the real user
> config and real library are never touched (record protection proof before/after).
> Abort if a phase exceeds ~2x its budget or a tool call retries 3+ times (SKILL.md
> "Time Budgets & Abort Guidance").
## Table of Contents
1. [LoRA List Page](#lora-list-page)
2. [Model Details](#model-details)
3. [Recipes](#recipes)
4. [Settings](#settings)
5. [Import/Export](#importexport)
---
## LoRA List Page
### Scenario: Page Load and Display
**Objective**: Verify the LoRA list page loads correctly and displays models.
**Steps**:
1. Navigate to `http://127.0.0.1:{PORT}/loras`
2. Wait for page title "LoRAs" to appear
3. Take snapshot to verify:
- Header with "LoRAs" title is visible
- Search/filter controls are present
- Grid/list view toggle exists
- LoRA cards are displayed (if models exist)
- Pagination controls (if applicable)
**Expected Result**: Page loads without errors, UI elements are present.
### Scenario: Search Functionality
**Objective**: Verify search filters LoRA models correctly.
**Steps**:
1. Ensure at least one LoRA exists with known name (e.g., "test-character")
2. Navigate to LoRA list page
3. Enter search term in search box: "test"
4. Press Enter or click search button
5. Wait for results to update
**Expected Result**: Only LoRAs matching search term are displayed.
**Verification Script**:
```python
# After search, verify filtered results
evaluate_script(function="""
() => {
const cards = document.querySelectorAll('.lora-card');
const names = Array.from(cards).map(c => c.dataset.name);
return { count: cards.length, names };
}
""")
```
### Scenario: Filter by Tags
**Objective**: Verify tag filtering works correctly.
**Steps**:
1. Navigate to LoRA list page
2. Click on a tag (e.g., "character", "style")
3. Wait for filtered results
**Expected Result**: Only LoRAs with selected tag are displayed.
### Scenario: View Mode Toggle
**Objective**: Verify grid/list view toggle works.
**Steps**:
1. Navigate to LoRA list page
2. Click list view button
3. Verify list layout
4. Click grid view button
5. Verify grid layout
**Expected Result**: View mode changes correctly, layout updates.
---
## Model Details
### Scenario: Open Model Details
**Objective**: Verify clicking a LoRA opens its details.
**Steps**:
1. Navigate to LoRA list page
2. Click on a LoRA card
3. Wait for details panel/modal to open
**Expected Result**: Details panel shows:
- Model name
- Preview image
- Metadata (trigger words, tags, etc.)
- Action buttons (edit, delete, etc.)
### Scenario: Edit Model Metadata
**Objective**: Verify metadata editing works end-to-end.
**Steps**:
1. Open a LoRA's details
2. Click "Edit" button
3. Modify trigger words field
4. Add/remove tags
5. Save changes
6. Refresh page
7. Reopen the same LoRA
**Expected Result**: Changes persist after refresh.
### Scenario: Delete Model
**Objective**: Verify model deletion works.
**Steps**:
1. Open a LoRA's details
2. Click "Delete" button
3. Confirm deletion in dialog
4. Wait for removal
**Expected Result**: Model removed from list, success message shown.
---
## Recipes
### Scenario: Recipe List Display
**Objective**: Verify recipes page loads and displays recipes.
**Steps**:
1. Navigate to `http://127.0.0.1:{PORT}/recipes`
2. Wait for "Recipes" title
3. Take snapshot
**Expected Result**: Recipe list displayed with cards/items.
### Scenario: Create New Recipe
**Objective**: Verify recipe creation workflow.
**Steps**:
1. Navigate to recipes page
2. Click "New Recipe" button
3. Fill recipe form:
- Name: "Test Recipe"
- Description: "E2E test recipe"
- Add LoRA models
4. Save recipe
5. Verify recipe appears in list
**Expected Result**: New recipe created and displayed.
### Scenario: Apply Recipe
**Objective**: Verify applying a recipe to ComfyUI.
**Steps**:
1. Open a recipe
2. Click "Apply" or "Load in ComfyUI"
3. Verify action completes
**Expected Result**: Recipe applied successfully.
---
## Settings
### Scenario: Settings Page Load
**Objective**: Verify settings page displays correctly.
**Steps**:
1. Navigate to `http://127.0.0.1:{PORT}/settings`
2. Wait for "Settings" title
3. Take snapshot
**Expected Result**: Settings form with various options displayed.
### Scenario: Change Setting and Restart
**Objective**: Verify settings persist after restart.
**Steps**:
1. Navigate to settings page
2. Change a setting (e.g., default view mode)
3. Save settings
4. Restart server: `python scripts/start_server.py --port {PORT} --restart --wait --timeout 30 --detach`
5. Refresh browser page
6. Navigate to settings
**Expected Result**: Changed setting value persists.
---
## Import/Export
### Scenario: Export Models List
**Objective**: Verify export functionality.
**Steps**:
1. Navigate to LoRA list
2. Click "Export" button
3. Select format (JSON/CSV)
4. Download file
**Expected Result**: File downloaded with correct data.
### Scenario: Import Models
**Objective**: Verify import functionality.
**Steps**:
1. Prepare import file
2. Navigate to import page
3. Upload file
4. Verify import results
**Expected Result**: Models imported successfully, confirmation shown.
---
## API Integration Tests
### Scenario: Verify API Endpoints
**Objective**: Verify backend API responds correctly.
**Test via browser console**:
```javascript
// List LoRAs
fetch('/loras/api/list').then(r => r.json()).then(console.log)
// Get LoRA details
fetch('/loras/api/detail/<id>').then(r => r.json()).then(console.log)
// Search LoRAs
fetch('/loras/api/search?q=test').then(r => r.json()).then(console.log)
```
**Expected Result**: APIs return valid JSON with expected structure.
---
## Console Error Monitoring
During all tests, monitor browser console for errors:
```python
# Check for JavaScript errors
messages = list_console_messages(types=["error"])
assert len(messages) == 0, f"Console errors found: {messages}"
```
## Network Request Verification
Verify key API calls are made:
```python
# List XHR requests
requests = list_network_requests(resourceTypes=["xhr", "fetch"])
# Look for specific endpoints
lora_list_requests = [r for r in requests if "/api/list" in r.get("url", "")]
assert len(lora_list_requests) > 0, "LoRA list API not called"
```
@@ -1,215 +0,0 @@
#!/usr/bin/env python3
"""
Example E2E test demonstrating LoRa Manager testing workflow.
This script shows how to:
1. Start the standalone server
2. Use Chrome DevTools MCP to interact with the UI
3. Verify functionality end-to-end
Note: This is a template. Actual execution requires Chrome DevTools MCP.
Port: pick a FREE port for the run — 8188 is commonly occupied by a live
ComfyUI (see the skill's Port Selection section). Set PORT below to e.g. 8199
when 8188 is taken. Always run against a SANDBOXED standalone server.
"""
import subprocess
import sys
# Choose the E2E port. 8188 is only the default candidate; use 8199 (or any
# free port checked with `ss -tlnp`) when 8188 is occupied by a live ComfyUI.
PORT = "8188"
def run_test():
"""Run example E2E test flow."""
print("=" * 60)
print("LoRa Manager E2E Test Example")
print("=" * 60)
# Step 1: Start server (detached so it survives the shell)
print("\n[1/5] Starting LoRa Manager standalone server...")
result = subprocess.run(
[sys.executable, "start_server.py", "--port", PORT, "--wait", "--timeout", "30", "--detach"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"Failed to start server: {result.stderr}")
return 1
print("Server ready!")
# Step 2: Open Chrome (manual step - show command)
print("\n[2/5] Open Chrome with debug mode:")
print(
f"google-chrome --remote-debugging-port=9222 "
f"--user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:{PORT}/loras"
)
print("(In actual test, this would be automated via MCP)")
# Step 3: Navigate and verify page load
print("\n[3/5] Page Load Verification:")
print(
f"""
MCP Commands to execute:
1. navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
2. wait_for(text="LoRAs", timeout=10000)
3. snapshot = take_snapshot()
"""
)
# Step 4: Test search functionality
print("\n[4/5] Search Functionality Test:")
print(
"""
MCP Commands to execute:
1. fill(uid="search-input", value="test")
2. press_key(key="Enter")
3. wait_for(text="Results", timeout=5000)
4. result = evaluate_script(function=`
() => {
const cards = document.querySelectorAll('.lora-card');
return { count: cards.length };
}
`)
"""
)
# Step 5: Verify API
print("\n[5/5] API Verification:")
print(
"""
MCP Commands to execute:
1. api_result = evaluate_script(function=`
async () => {
const response = await fetch('/loras/api/list');
const data = await response.json();
return { count: data.length, status: response.status };
}
`)
2. Verify api_result['status'] == 200
"""
)
print("\n" + "=" * 60)
print("Test flow completed!")
print("=" * 60)
return 0
def example_restart_flow():
"""Example: Testing configuration change that requires restart."""
print("\n" + "=" * 60)
print("Example: Server Restart Flow")
print("=" * 60)
print(
f"""
Scenario: Change setting and verify after restart
Steps:
1. Navigate to settings page
- navigate_page(type="url", url="http://127.0.0.1:{PORT}/settings")
2. Change a setting (e.g., theme)
- fill(uid="theme-select", value="dark")
- click(uid="save-settings-button")
3. Restart server
- subprocess.run([python, "start_server.py", "--port", "{PORT}", "--restart", "--wait", "--detach"])
4. Refresh browser
- navigate_page(type="reload", ignoreCache=True)
- wait_for(text="LoRAs", timeout=15000)
5. Verify setting persisted
- navigate_page(type="url", url="http://127.0.0.1:{PORT}/settings")
- theme = evaluate_script(function="() => document.querySelector('#theme-select').value")
- assert theme == "dark"
"""
)
def example_modal_interaction():
"""Example: Testing modal dialog interaction."""
print("\n" + "=" * 60)
print("Example: Modal Dialog Interaction")
print("=" * 60)
print(
"""
Scenario: Add new LoRA via modal
Steps:
1. Open modal
- click(uid="add-lora-button")
- wait_for(text="Add LoRA", timeout=3000)
2. Fill form
- fill_form(elements=[
{"uid": "lora-name", "value": "Test Character"},
{"uid": "lora-path", "value": "/models/test.safetensors"},
])
3. Submit
- click(uid="modal-submit-button")
4. Verify success
- wait_for(text="Successfully added", timeout=5000)
- snapshot = take_snapshot()
"""
)
def example_network_monitoring():
"""Example: Network request monitoring."""
print("\n" + "=" * 60)
print("Example: Network Request Monitoring")
print("=" * 60)
print(
f"""
Scenario: Verify API calls during user interaction
Steps:
1. Clear network log (implicit on navigation)
- navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
2. Perform action that triggers API call
- fill(uid="search-input", value="character")
- press_key(key="Enter")
3. List network requests
- requests = list_network_requests(resourceTypes=["xhr", "fetch"])
4. Find search API call
- search_requests = [r for r in requests if "/api/search" in r.get("url", "")]
- assert len(search_requests) > 0, "Search API was not called"
5. Get request details
- if search_requests:
details = get_network_request(reqid=search_requests[0]["reqid"])
- Verify request method, response status, etc.
"""
)
if __name__ == "__main__":
print("LoRa Manager E2E Test Examples\n")
print("This script demonstrates E2E testing patterns.\n")
print("Note: Actual execution requires Chrome DevTools MCP connection.\n")
run_test()
example_restart_flow()
example_modal_interaction()
example_network_monitoring()
print("\n" + "=" * 60)
print("All examples shown!")
print("=" * 60)
+24
View File
@@ -170,6 +170,10 @@ The system runs in two modes:
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc.
- Request handlers in `py/routes/handlers/` implement route logic
- All routes use aiohttp, return `web.json_response` or `web.Response`
- Endpoints consumed by the companion browser extension (lm-civitai-extension)
MUST also accept `GET` with query-string params: the extension is GET-only by
convention (see its AGENTS.md), even for state-changing operations such as
`GET /api/lm/recipe/{recipe_id}/reimport`
### Recipe System
@@ -215,6 +219,26 @@ The system runs in two modes:
- Vanilla JS tests: `tests/frontend/**/*.test.js` with jsdom; setup in `tests/frontend/setup.js`
- Vue widget tests: `vue-widgets/tests/**/*.test.ts` with jsdom + `@vue/test-utils`
### UI Verification (manual default)
UI/layout changes are verified by the user by eye — do NOT spin up a sandbox,
standalone server, or browser automation to "prove" a visual fix. Ask the user to
look instead. The full browser E2E ceremony (server + Chrome DevTools MCP +
screenshots) is slow, token-heavy, and fragile; reserve it for genuine
server+browser integration bugs, and only when the user explicitly agrees.
If a cross-layer issue ever needs a live server, the sandboxed helpers live in
`scripts/e2e/` (`start_server.py`, `wait_for_server.py`). Non-negotiable rules:
- Always launch with `--settings-path <sandbox>/settings` and sandboxed
`folder_paths` under `/tmp` — the repo folder is the real plugin folder and a
`settings.json` there is read by the live instance. Never touch real config or
real model libraries.
- Never kill a process you did not start; `start_server.py` tracks its own PIDs
via pidfile and refuses to touch unrelated processes on the port.
- Abort after ~30 minutes or 3 consecutive tool failures; report `BLOCKED` with
observed state instead of retrying blindly. Clean up sandbox and server after.
## Key Integration Points
- **Settings:** Stored in the user config directory (via `platformdirs`) or portable mode (`"use_portable_settings": true`)
+2 -2
View File
File diff suppressed because one or more lines are too long
+427 -395
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -137,7 +137,7 @@ and must be normalized. `en` = keep the English word as-is.
| Term | Use | Fix |
|---|---|---|
| recipe | Rezept/Rezepte | 5 leftover English "Recipe" keys → Rezept (e.g. `globalContextMenu.repairRecipes.label`, `toast.recipes.recipeSaved`) |
| recipe | Rezept/Rezepte | leftover English "Recipe" keys → Rezept (e.g. `toast.recipes.recipeSaved`) |
| base model | pick Basis-Modell or Basismodell | currently 27× hyphenated vs 15× closed |
| metadata | Metadaten | 4 keys use "Modelldaten" (`onboarding.steps.fetch.title/content`) → Metadaten |
| bulk | pick Massen- or Sammelmodus | `loras.controls.bulk.action` = "Massen" reads as "crowds" — use "Massenbearbeitung"/"Mehrfachauswahl" |
@@ -193,7 +193,7 @@ and must be normalized. `en` = keep the English word as-is.
| Checkpoint | Checkpoint or チェックポイント (pick one) | 3 variants: Checkpoint (~14), checkpoint lowercase (4), チェックポイント (4, e.g. `settings.priorityTags.modelTypes.checkpoint`) |
| Embedding | Embedding | 4 keys lowercase "embedding" mid-sentence |
| bulk | 一括 | `modals.checkUpdates.tip` "バルクモード" → 一括モード |
| recipe counter | 件 or 個 | `repairRecipes.success` uses 件, `.cancelled` uses 個 — unify |
| recipe counter | 件 or 個 | `globalContextMenu.rematchRecipes.success` uses 件, `.cancelled` uses 個 — unify |
### ko
+58
View File
@@ -0,0 +1,58 @@
# CivitAI image imports can end up with 0 LoRAs
## Symptom
Importing a CivitAI image URL can produce a recipe with **zero LoRA
entries**, even though the image page lists LoRAs in its resource panel.
Reported example: `https://civitai.red/images/140818889` was imported as a
local recipe with 0 LoRAs, while the page shows 3 LoRAs. Some images (e.g.
NSFW / higher browsing level) additionally require a login to view, so their
data is not publicly reachable at all.
## Root cause
URL imports use only two data sources:
1. **CivitAI REST image API**`GET /api/v1/images?imageId=<id>&nsfw=X&withMeta=true``meta`
2. **Embedded image metadata** — EXIF/XMP read from the downloaded bytes
For the same image both sources can be empty, and the one source that does
contain the data is never queried. Verified for image 140818889:
| Source | What it returned |
|---|---|
| REST image API | `meta` holds only a prompt; `modelVersionIds: []`; no `resources`/`hashes`; `baseModel: null` |
| Downloaded image | PNG with **no EXIF/XMP** (the CDN URL ends in `.jpeg`, the body is PNG) |
| Image page HTML | `__NEXT_DATA__` embeds the trpc `image.getGenerationData` result → full `resources` list: 3 LoRAs, each with `modelId`, `modelVersionId`, `modelName`, `modelType`, `versionName`, `baseModel` |
Key points:
- The page's resource panel is fed by an **internal, non-public trpc
endpoint**, not by the public REST image API.
- That internal endpoint is **login-gated** for some content — the
"requires login" symptom.
- Even with the version IDs in hand, `/model-versions/{id}` for these
(Krea) versions returns **no `sha256`**, so an exact local-file hash match
is impossible; only model/version identity is recoverable.
## Conclusion / status
0-LoRA imports are a data-source gap: public REST meta and image EXIF are
both empty, while the only complete source (page generation data) is
internal, sometimes login-gated, and not used by the importer.
Such imports **cannot be reliably auto-repaired/completed** by the backend
alone. The old "Repair Metadata" feature only re-fetched the same incomplete
REST meta and could not fix them; it was deprecated and has been removed.
**Fixed via the companion browser extension.** When the extension is
installed with a valid license, it scrapes the image page's internal trpc
generation data with the user's session and calls the payload-capable
re-import endpoint (`POST /api/lm/recipe/{recipe_id}/reimport` with
`image_url`/`name`/`resources`/`gen_params`/`base_model`/`tags` query
params), which rebuilds the recipe from the caller-supplied metadata. The
web UI delegates re-import of CivitAI-image-sourced recipes to the extension
automatically (probe + `lm:reimport*` DOM events); without the extension,
re-import silently falls back to the native path, which remains limited by
the data-source gap documented above.
@@ -0,0 +1,92 @@
# Reconcile 的 Windows 大小写回退分支 - 待验证清单
> **状态**: 待 Windows 环境验证 | **创建日期**: 2026-09-11
> **相关文件**: `py/services/model_scanner.py` (`ModelScanner._reconcile_cache`)
> **相关历史**: #871 (`76ee59cd`, 路径重叠去重)、#1108 (按文件夹扫描的需求)
---
## 背景
Refresh 按钮走的是 `_reconcile_cache()`(快速增量对账)。2026-09-11 做了一轮性能优化,把两处"预防性"的
realpath 全量遍历改成按需触发(详见下方"已完成")。优化后,一次零变更 Refresh 在 5 万文件库上从
~1400 ms 降到 ~120 ms。
清理过程中发现**唯一一处遗留的可疑点**:Windows 专属的大小写不敏感回退分支。它无法在 Linux 上验证,
因此单独记录,留待 Windows 机器上确认。
---
## 待验证分支(现状)
`py/services/model_scanner.py``_reconcile_cache()` 的 walk 循环内:
```python
# Try case-insensitive match on Windows
if os.name == 'nt':
lower_path = file_path.lower()
matched = False
for cached_path in cached_paths: # 每个未命中文件都全量扫一遍缓存
if cached_path.lower() == lower_path:
found_paths.add(cached_path)
matched = True
break
if matched:
continue
```
它排在精确匹配(`file_path in cached_paths`)和 realpath 别名匹配之后,只有**未命中**的文件才会走到。
### 为什么可疑
1. **可能不可达**Windows 上 `os.path.realpath()` 会返回磁盘上的真实大小写,因此"缓存路径大小写与磁盘
不一致"的情形,理论上已经被上一步的 realpath 别名匹配覆盖。若如此,这段就是纯冗余代码。
2. **一旦可达就是 O(N×M)**:每个未命中文件都要遍历全部 `cached_paths` 做小写比较。若某种路径写法让
整个库都变成"未命中"(例如缓存里的盘符/大小写形式与 walk 结果系统性不一致),一次 Refresh 会退化
成 文件数 × 缓存条目数 次字符串比较,比真实 IO 还贵。
3. **没有测试覆盖**`tests/services/test_model_scanner.py` 没有任何针对该分支的用例(它在 Linux 上
`os.name == 'nt'` 短路,无法覆盖)。
---
## 待办
- [ ] **验证可达性**:在 Windows 上构造"缓存路径与磁盘真实大小写不一致"的场景,确认 realpath 别名匹配
是否已经命中,即上面的 `if os.name == 'nt'` 分支是否还有进入的必要。
- [ ] **若不可达 / 冗余**:删除该分支,并在删除处留注释说明 realpath 已覆盖大小写归一(附验证记录)。
- [ ] **若可达**:保留语义但改成 O(1)——预先构建一次 `lower_path -> cached_path` 映射(与
`cached_real_paths` 同样按需、懒构建),把内层全量扫描换成一次字典查询。
- [ ] **补一个 Windows-only 的回归测试**`pytest.mark.skipif(os.name != "nt", ...)`),锁定最终结论。
- [ ] 把验证结论回填到本文件,并同步更新状态行。
---
## 验证方法(Windows
1. **构造不一致的大小写**:让缓存里的 `file_path` 与磁盘实际路径大小写不同(例如改过盘符/目录大小写,
或从另一台机器迁移了 `settings.json` 与持久化缓存),然后在 UI 点 Refresh。
2. **看后端日志判据**
- 若 realpath 已覆盖 → 日志应显示 `Cache reconciliation completed in X seconds. Added 0, removed 0 models.`
且**没有** `Found N new files to process` / `Processing <path>`
- 若回退分支在起作用 → 同样应该是 `Added 0, removed 0`(因为 `found_paths` 被补上),这是"分支可达"
的证据;反之若出现大量 `Processing ...` 并重新 hash,说明连回退分支也没命中,问题更严重
(缓存路径被当成了新文件 + 旧条目被删)。
3. **跑测试**`python -m pytest tests/services/test_model_scanner.py -k reconcile`(该文件在 Windows 上会
真实执行 `os.name == 'nt'` 分支)。
4. **量化**:如果需要,可在 `_reconcile_cache` 里临时插桩统计该分支的进入次数与内层迭代次数,确认是否为 0。
---
## 已完成(本轮优化,供对照)
同一次清理里已经落地并验证的部分(Linux,5 万文件库):
- `cached_real_paths` 别名映射改为**首次未命中时**懒构建(原来每次 Refresh 都对全部缓存条目算一次 realpath)。
- 每个文件的 `realpath` 移到精确命中检查**之后**(原来对每个文件都算,命中即丢弃)。
- `get_model_roots()` 在新增文件处理阶段只快照一次(原来每个新文件重读一次)。
- 全量去重 pass 加了 O(1) 前置判断(`cached_size_before != len(cached_paths) or total_added > 0`),
零变更且缓存干净时跳过;快照本身含重复路径时仍会自愈。
结果:零变更 Refresh 5 万文件 **~1400 ms → ~120 ms**;根目录顺序/符号链接别名翻转场景仍是
`re-processed=0`(不重新读 metadata、不重新 hash)。测试:`tests/services/test_model_scanner.py`
47 项、全量后端 2567 项全部通过。
+39 -26
View File
@@ -212,20 +212,10 @@
"none": "Alle {typePlural} verfügen bereits über Lizenzmetadaten",
"error": "Lizenzmetadaten für {typePlural} konnten nicht aktualisiert werden: {message}"
},
"repairRecipes": {
"label": "Rezept-Daten reparieren",
"loading": "Rezept-Daten werden repariert...",
"success": "{count} Rezepte erfolgreich repariert.",
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
"error": "Rezept-Reparatur fehlgeschlagen: {message}"
},
"rematchRecipes": {
"label": "Rezepte lokalen Modellen neu zuordnen",
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
"success": "{entries} Einträge in {recipes} Rezepten zugeordnet",
"successErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
"allFailed": "Zuordnung fehlgeschlagen für {failures} von {total} Rezepten",
"noMatch": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
"cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)",
"error": "Zuordnung der Rezepte fehlgeschlagen: {message}"
},
@@ -484,6 +474,8 @@
"layoutSettings": {
"groupByModel": "Nach Modell gruppieren",
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes CivitAI-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
"stickyControls": "Aktionsleiste sichtbar halten",
"stickyControlsHelp": "Wenn aktiviert, bleibt die Aktionsleiste (Aktualisieren, Herunterladen usw.) beim Scrollen zusammen mit der Breadcrumb-Navigation oben angeheftet.",
"displayDensity": "Anzeige-Dichte",
"displayDensityOptions": {
"default": "Standard",
@@ -819,7 +811,6 @@
"setContentRating": "Inhaltsbewertung für alle festlegen",
"copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren",
"repairMetadata": "Metadaten der Auswahl reparieren",
"rematchMetadata": "Ausgewählte mit lokalen Modellen abgleichen",
"reimportMetadata": "Aus Quelle neu importieren",
"checkUpdates": "Auswahl auf Updates prüfen",
@@ -875,7 +866,6 @@
"replacePreview": "Vorschau ersetzen",
"setContentRating": "Inhaltsbewertung festlegen",
"moveToFolder": "In Ordner verschieben",
"repairMetadata": "Metadaten reparieren",
"rematchMetadata": "Mit lokalen Modellen abgleichen",
"reimportMetadata": "Aus Quelle neu importieren",
"excludeModel": "Modell ausschließen",
@@ -1128,13 +1118,6 @@
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
"prepareError": "Fehler beim Vorbereiten der LoRAs für den Download: {message}"
},
"repair": {
"starting": "Rezept-Metadaten werden repariert...",
"success": "Rezept-Metadaten erfolgreich repariert",
"skipped": "Rezept bereits in der neuesten Version, keine Reparatur erforderlich",
"failed": "Rezept-Reparatur fehlgeschlagen: {message}",
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
},
"reimport": {
"starting": "Rezept wird aus Quelle neu importiert...",
"success": "Rezept erfolgreich neu importiert",
@@ -1515,6 +1498,41 @@
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
"downloadButton": "{count} LoRA(s) herunterladen"
},
"rematchOptions": {
"title": "Rezepte neu zuordnen",
"messageGlobal": "Alle Rezepte werden mit Ihrer lokalen Modellbibliothek abgeglichen.",
"messageSingle": "Dieses Rezept wird mit Ihrer lokalen Modellbibliothek abgeglichen.",
"messageBulk": "{count} ausgewählte Rezepte werden mit Ihrer lokalen Modellbibliothek abgeglichen.",
"relaxedLabel": "Fehlende Modelle auch per Dateiname neu verbinden",
"relaxedDescription": "Diese Modelle könnten auch per Download behoben werden — der Download ist genauer. Übereinstimmungen verknüpfen möglicherweise eine andere Version; sie werden zur Überprüfung aufgelistet und können rückgängig gemacht werden.",
"confirmButton": "Neu zuordnen"
},
"rematchResults": {
"undo": "Rückgängig",
"undone": "Rückgängig gemacht",
"undoFailed": "Rückgängigmachen der Neuordnung fehlgeschlagen: {message}"
},
"rematchSummary": {
"title": "Zusammenfassung der Neuordnung",
"successMessage": "{entries} Einträge zugeordnet",
"failed": "Neuordnung fehlgeschlagen",
"completedWithWarnings": "Neuordnung abgeschlossen — Überprüfung empfohlen",
"cancelledNote": "Der Vorgang wurde vorzeitig abgebrochen — die Zahlen sind unvollständig.",
"statMatched": "Zugeordnete Einträge",
"statReview": "Zu überprüfen",
"statUnresolved": "Nicht zugeordnet",
"statErrors": "Fehler",
"reviewSection": "Dateinamen-Übereinstimmungen zur Überprüfung ({count})",
"columnRecipe": "Rezept",
"columnEntry": "Eintrag",
"columnFile": "Zugeordnete Datei",
"columnUndo": "Rückgängig",
"copyReport": "Bericht kopieren",
"close": "Schließen",
"scope_global": "Alle Rezepte",
"scope_bulk": "Ausgewählte Rezepte",
"scope_single": "Einzelnes Rezept"
},
"exampleAccess": {
"title": "Lokale Beispielbilder",
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
@@ -2182,6 +2200,7 @@
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
"created": "Rezept erfolgreich erstellt",
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
"unresolvableMarkedForReconnect": "{count} nicht auflösbare Einträge markiert — sie können jetzt mit einem lokalen LoRA neu verbunden werden.",
"noPreviousRecipe": "Kein vorheriges Rezept verfügbar",
"noNextRecipe": "Kein weiteres Rezept verfügbar",
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
@@ -2236,16 +2255,10 @@
"batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}",
"batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt",
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
"rematchComplete": "{entries} Einträge in {recipes} Rezepten zugeordnet",
"rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
"rematchAllFailed": "Zuordnung fehlgeschlagen für {failures} von {total} ausgewählten Rezepten",
"rematchUnmatched": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
"reimporting": "Rezept wird aus Quelle neu importiert...",
"reimportingViaExtension": "Rezept {current}/{total} wird über die Browser-Erweiterung neu importiert...",
"reimportSuccess": "Rezept erfolgreich neu importiert",
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
+39 -26
View File
@@ -212,20 +212,10 @@
"none": "All {typePlural} already have license metadata",
"error": "Failed to refresh license metadata for {typePlural}: {message}"
},
"repairRecipes": {
"label": "Repair recipes data",
"loading": "Repairing recipe data...",
"success": "Successfully repaired {count} recipes.",
"cancelled": "Repair cancelled. {count} recipes were repaired.",
"error": "Recipe repair failed: {message}"
},
"rematchRecipes": {
"label": "Rematch recipes to local models",
"loading": "Rematching recipes to local models...",
"success": "Matched {entries} entries across {recipes} recipes",
"successErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
"allFailed": "Rematch failed for {failures} of {total} recipes",
"noMatch": "No local match found for {entries} entries in {recipes} recipes",
"cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).",
"error": "Recipe rematch failed: {message}"
},
@@ -484,6 +474,8 @@
"layoutSettings": {
"groupByModel": "Group by Model",
"groupByModelHelp": "When enabled, only the latest version of each CivitAI model is shown as a single card. Older versions are hidden.",
"stickyControls": "Keep Action Bar Visible",
"stickyControlsHelp": "When enabled, the action bar (Refresh, Download, etc.) stays pinned at the top while scrolling, together with the breadcrumb navigation.",
"displayDensity": "Display Density",
"displayDensityOptions": {
"default": "Default",
@@ -819,7 +811,6 @@
"setContentRating": "Set Content Rating for Selected",
"copyAll": "Copy Selected Syntax",
"refreshAll": "Refresh Selected Metadata",
"repairMetadata": "Repair Metadata for Selected",
"rematchMetadata": "Rematch Selected to Local Models",
"reimportMetadata": "Re-import from Source",
"checkUpdates": "Check Updates for Selected",
@@ -875,7 +866,6 @@
"replacePreview": "Replace Preview",
"setContentRating": "Set Content Rating",
"moveToFolder": "Move to Folder",
"repairMetadata": "Repair metadata",
"rematchMetadata": "Rematch to local models",
"reimportMetadata": "Re-import from Source",
"excludeModel": "Exclude Model",
@@ -1128,13 +1118,6 @@
"getInfoFailed": "Failed to get information for missing LoRAs",
"prepareError": "Error preparing LoRAs for download: {message}"
},
"repair": {
"starting": "Repairing recipe metadata...",
"success": "Recipe metadata repaired successfully",
"skipped": "Recipe already at latest version, no repair needed",
"failed": "Failed to repair recipe: {message}",
"missingId": "Cannot repair recipe: Missing recipe ID"
},
"reimport": {
"starting": "Re-importing recipe from source...",
"success": "Recipe re-imported successfully",
@@ -1515,6 +1498,41 @@
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
"downloadButton": "Download {count} LoRA(s)"
},
"rematchOptions": {
"title": "Rematch Recipes",
"messageGlobal": "All recipes will be scanned against your local model library.",
"messageSingle": "This recipe will be scanned against your local model library.",
"messageBulk": "{count} selected recipe(s) will be scanned against your local model library.",
"relaxedLabel": "Also reconnect missing models by file name",
"relaxedDescription": "These models could also be fixed by downloading — download is more accurate. Matches may link a different version; they'll be listed for review and can be undone.",
"confirmButton": "Rematch"
},
"rematchResults": {
"undo": "Undo",
"undone": "Undone",
"undoFailed": "Failed to undo rematch: {message}"
},
"rematchSummary": {
"title": "Rematch Summary",
"successMessage": "Matched {entries} entries",
"failed": "Rematch failed",
"completedWithWarnings": "Rematch completed — review recommended",
"cancelledNote": "Run cancelled before completion — counts are partial.",
"statMatched": "Matched entries",
"statReview": "Needs review",
"statUnresolved": "Unresolved",
"statErrors": "Errors",
"reviewSection": "Filename matches to review ({count})",
"columnRecipe": "Recipe",
"columnEntry": "Entry",
"columnFile": "Matched file",
"columnUndo": "Undo",
"copyReport": "Copy Report",
"close": "Close",
"scope_global": "All recipes",
"scope_bulk": "Selected recipes",
"scope_single": "Single recipe"
},
"exampleAccess": {
"title": "Local Example Images",
"message": "No local example images found for this model. View options:",
@@ -2182,6 +2200,7 @@
"createMissingData": "Missing required data to create recipe",
"created": "Recipe created successfully",
"noMissingLoras": "No missing LoRAs to download",
"unresolvableMarkedForReconnect": "{count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "No previous recipe available",
"noNextRecipe": "No next recipe available",
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
@@ -2236,16 +2255,10 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No recipes selected",
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
"repairBulkFailed": "Failed to repair selected recipes: {message}",
"rematchComplete": "Matched {entries} entries across {recipes} recipes",
"rematchCompleteErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
"rematchAllFailed": "Rematch failed for {failures} of {total} selected recipes",
"rematchUnmatched": "No local match found for {entries} entries in {recipes} recipes",
"rematchSkipped": "No rematch needed for any of the {total} selected recipes",
"rematchFailed": "Failed to rematch selected recipes: {message}",
"reimporting": "Re-importing recipe from source...",
"reimportingViaExtension": "Re-importing recipe {current}/{total} via browser extension...",
"reimportSuccess": "Recipe re-imported successfully",
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
"reimportBulkFailed": "Failed to re-import some recipes",
+39 -26
View File
@@ -212,20 +212,10 @@
"none": "Todos los {typePlural} ya tienen metadatos de licencia",
"error": "No se pudieron actualizar los metadatos de licencia de los {typePlural}: {message}"
},
"repairRecipes": {
"label": "Reparar datos de recetas",
"loading": "Reparando datos de recetas...",
"success": "Se repararon con éxito {count} recetas.",
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
"error": "Error al reparar recetas: {message}"
},
"rematchRecipes": {
"label": "Reasociar recetas con modelos locales",
"loading": "Reasociando recetas con modelos locales...",
"success": "{entries} entradas asociadas en {recipes} recetas",
"successErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
"allFailed": "Falló la reasociación de {failures} de {total} recetas",
"noMatch": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
"cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)",
"error": "Falló la reasociación de recetas: {message}"
},
@@ -484,6 +474,8 @@
"layoutSettings": {
"groupByModel": "Agrupar por modelo",
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de CivitAI como una tarjeta única. Las versiones anteriores están ocultas.",
"stickyControls": "Mantener visible la barra de acciones",
"stickyControlsHelp": "Cuando está activado, la barra de acciones (Actualizar, Descargar, etc.) permanece fijada en la parte superior al desplazarse, junto con la navegación por rutas.",
"displayDensity": "Densidad de visualización",
"displayDensityOptions": {
"default": "Predeterminado",
@@ -819,7 +811,6 @@
"setContentRating": "Establecer clasificación de contenido para todos",
"copyAll": "Copiar toda la sintaxis",
"refreshAll": "Actualizar todos los metadatos",
"repairMetadata": "Reparar metadatos de la selección",
"rematchMetadata": "Reasociar los seleccionados con modelos locales",
"reimportMetadata": "Reimportar desde origen",
"checkUpdates": "Comprobar actualizaciones para la selección",
@@ -875,7 +866,6 @@
"replacePreview": "Reemplazar vista previa",
"setContentRating": "Establecer clasificación de contenido",
"moveToFolder": "Mover a carpeta",
"repairMetadata": "Reparar metadatos",
"rematchMetadata": "Reasociar con modelos locales",
"reimportMetadata": "Reimportar desde origen",
"excludeModel": "Excluir modelo",
@@ -1128,13 +1118,6 @@
"getInfoFailed": "Error al obtener información de LoRAs faltantes",
"prepareError": "Error preparando LoRAs para descarga: {message}"
},
"repair": {
"starting": "Reparando metadatos de la receta...",
"success": "Metadatos de la receta reparados con éxito",
"skipped": "La receta ya está en la última versión, no se necesita reparación",
"failed": "Error al reparar la receta: {message}",
"missingId": "No se puede reparar la receta: falta el ID de la receta"
},
"reimport": {
"starting": "Reimportando receta desde origen...",
"success": "Receta reimportada exitosamente",
@@ -1515,6 +1498,41 @@
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
"downloadButton": "Descargar {count} LoRA(s)"
},
"rematchOptions": {
"title": "Reasociar recetas",
"messageGlobal": "Se escanearán todas las recetas contra tu biblioteca local de modelos.",
"messageSingle": "Se escaneará esta receta contra tu biblioteca local de modelos.",
"messageBulk": "Se escanearán {count} receta(s) seleccionada(s) contra tu biblioteca local de modelos.",
"relaxedLabel": "Reconectar también los modelos faltantes por nombre de archivo",
"relaxedDescription": "Estos modelos también se pueden corregir descargándolos; la descarga es más precisa. Las coincidencias pueden enlazar una versión diferente; se listarán para su revisión y se pueden deshacer.",
"confirmButton": "Reasociar"
},
"rematchResults": {
"undo": "Deshacer",
"undone": "Deshecho",
"undoFailed": "No se pudo deshacer la reasociación: {message}"
},
"rematchSummary": {
"title": "Resumen de la reasociación",
"successMessage": "{entries} entradas asociadas",
"failed": "Falló la reasociación",
"completedWithWarnings": "Reasociación completada — se recomienda revisar",
"cancelledNote": "Ejecución cancelada antes de completarse — los recuentos son parciales.",
"statMatched": "Entradas asociadas",
"statReview": "Por revisar",
"statUnresolved": "Sin coincidencia",
"statErrors": "Errores",
"reviewSection": "Coincidencias por nombre de archivo para revisar ({count})",
"columnRecipe": "Receta",
"columnEntry": "Entrada",
"columnFile": "Archivo coincidente",
"columnUndo": "Deshacer",
"copyReport": "Copiar informe",
"close": "Cerrar",
"scope_global": "Todas las recetas",
"scope_bulk": "Recetas seleccionadas",
"scope_single": "Receta individual"
},
"exampleAccess": {
"title": "Imágenes de ejemplo locales",
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
@@ -2182,6 +2200,7 @@
"createMissingData": "Faltan datos necesarios para crear la receta",
"created": "Receta creada exitosamente",
"noMissingLoras": "No hay LoRAs faltantes para descargar",
"unresolvableMarkedForReconnect": "Se marcaron {count} entrada(s) no resoluble(s) — ahora se pueden reconectar a un LoRA local.",
"noPreviousRecipe": "No hay receta anterior disponible",
"noNextRecipe": "No hay siguiente receta disponible",
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
@@ -2236,16 +2255,10 @@
"batchImportBrowseFailed": "No se pudo examinar el directorio: {message}",
"batchImportDirectorySelected": "Directorio seleccionado: {path}",
"noRecipesSelected": "No se han seleccionado recetas",
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
"rematchComplete": "{entries} entradas asociadas en {recipes} recetas",
"rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
"rematchAllFailed": "Falló la reasociación de {failures} de {total} recetas seleccionadas",
"rematchUnmatched": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
"reimporting": "Reimportando receta desde origen...",
"reimportingViaExtension": "Reimportando receta {current}/{total} mediante la extensión del navegador...",
"reimportSuccess": "Receta reimportada exitosamente",
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
"reimportBulkFailed": "Error al reimportar algunas recetas",
+39 -26
View File
@@ -212,20 +212,10 @@
"none": "Tous les {typePlural} possèdent déjà des métadonnées de licence",
"error": "Échec de l'actualisation des métadonnées de licence pour les {typePlural} : {message}"
},
"repairRecipes": {
"label": "Réparer les données de Recipes",
"loading": "Réparation des données de Recipes...",
"success": "{count} Recipes réparées avec succès.",
"cancelled": "Réparation annulée. {count} Recipes ont été réparées.",
"error": "Échec de la réparation des Recipes : {message}"
},
"rematchRecipes": {
"label": "Réassocier les Recipes aux modèles locaux",
"loading": "Réassociation des Recipes aux modèles locaux...",
"success": "{entries} entrées associées dans {recipes} Recipes",
"successErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
"allFailed": "Échec de la réassociation de {failures} Recipes sur {total}",
"noMatch": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
"cancelled": "Réassociation annulée. {recipes} Recipes mises à jour ({entries} entrées)",
"error": "Échec de la réassociation des Recipes : {message}"
},
@@ -484,6 +474,8 @@
"layoutSettings": {
"groupByModel": "Grouper par modèle",
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle CivitAI s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
"stickyControls": "Garder la barre d'actions visible",
"stickyControlsHelp": "Lorsque activé, la barre d'actions (Actualiser, Télécharger, etc.) reste épinglée en haut lors du défilement, avec la navigation par fil d'Ariane.",
"displayDensity": "Densité d'affichage",
"displayDensityOptions": {
"default": "Par défaut",
@@ -819,7 +811,6 @@
"setContentRating": "Définir la classification du contenu pour tous",
"copyAll": "Copier toute la syntaxe",
"refreshAll": "Actualiser toutes les métadonnées",
"repairMetadata": "Réparer les métadonnées de la sélection",
"rematchMetadata": "Réassocier la sélection aux modèles locaux",
"reimportMetadata": "Ré-importer depuis la source",
"checkUpdates": "Vérifier les mises à jour pour la sélection",
@@ -875,7 +866,6 @@
"replacePreview": "Remplacer l'aperçu",
"setContentRating": "Définir la classification du contenu",
"moveToFolder": "Déplacer vers un dossier",
"repairMetadata": "Réparer les métadonnées",
"rematchMetadata": "Réassocier aux modèles locaux",
"reimportMetadata": "Ré-importer depuis la source",
"excludeModel": "Exclure le modèle",
@@ -1128,13 +1118,6 @@
"getInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
"prepareError": "Erreur lors de la préparation des LoRAs pour le téléchargement : {message}"
},
"repair": {
"starting": "Réparation des métadonnées de la Recipe...",
"success": "Métadonnées de la Recipe réparées avec succès",
"skipped": "Recette déjà à la version la plus récente, aucune réparation nécessaire",
"failed": "Échec de la réparation de la Recipe : {message}",
"missingId": "Impossible de réparer la Recipe : ID de Recipe manquant"
},
"reimport": {
"starting": "Ré-import de la Recipe depuis la source...",
"success": "Recette ré-importée avec succès",
@@ -1515,6 +1498,41 @@
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
"downloadButton": "Télécharger {count} LoRA(s)"
},
"rematchOptions": {
"title": "Réassocier les Recipes",
"messageGlobal": "Toutes les Recipes seront analysées par rapport à votre bibliothèque de modèles locale.",
"messageSingle": "Cette Recipe sera analysée par rapport à votre bibliothèque de modèles locale.",
"messageBulk": "{count} Recipes sélectionnées seront analysées par rapport à votre bibliothèque de modèles locale.",
"relaxedLabel": "Reconnecter aussi les modèles manquants par nom de fichier",
"relaxedDescription": "Ces modèles peuvent aussi être corrigés par téléchargement — le téléchargement est plus précis. Les correspondances peuvent associer une version différente ; elles seront listées pour vérification et peuvent être annulées.",
"confirmButton": "Réassocier"
},
"rematchResults": {
"undo": "Annuler",
"undone": "Annulé",
"undoFailed": "Échec de l'annulation de la réassociation : {message}"
},
"rematchSummary": {
"title": "Résumé de la réassociation",
"successMessage": "{entries} entrées associées",
"failed": "Échec de la réassociation",
"completedWithWarnings": "Réassociation terminée — vérification recommandée",
"cancelledNote": "Exécution annulée avant la fin — les décomptes sont partiels.",
"statMatched": "Entrées associées",
"statReview": "À vérifier",
"statUnresolved": "Sans correspondance",
"statErrors": "Erreurs",
"reviewSection": "Correspondances par nom de fichier à vérifier ({count})",
"columnRecipe": "Recipe",
"columnEntry": "Entrée",
"columnFile": "Fichier correspondant",
"columnUndo": "Annuler",
"copyReport": "Copier le rapport",
"close": "Fermer",
"scope_global": "Toutes les Recipes",
"scope_bulk": "Recipes sélectionnées",
"scope_single": "Une seule Recipe"
},
"exampleAccess": {
"title": "Images d'exemple locales",
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
@@ -2182,6 +2200,7 @@
"createMissingData": "Données requises manquantes pour créer le Recipe",
"created": "Recipe créé avec succès",
"noMissingLoras": "Aucun LoRA manquant à télécharger",
"unresolvableMarkedForReconnect": "{count} entrées irrésolubles marquées — elles peuvent maintenant être reconnectées à un LoRA local.",
"noPreviousRecipe": "Aucune Recipe précédente",
"noNextRecipe": "Aucune Recipe suivante",
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
@@ -2236,16 +2255,10 @@
"batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}",
"batchImportDirectorySelected": "Dossier sélectionné : {path}",
"noRecipesSelected": "Aucune Recipe sélectionnée",
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})",
"repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} Recipes sélectionnées",
"repairBulkFailed": "Échec de la réparation des Recipes sélectionnées : {message}",
"rematchComplete": "{entries} entrées associées dans {recipes} Recipes",
"rematchCompleteErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
"rematchAllFailed": "Échec de la réassociation de {failures} Recipes sélectionnées sur {total}",
"rematchUnmatched": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
"rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation",
"rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}",
"reimporting": "Ré-import de la Recipe depuis la source...",
"reimportingViaExtension": "Ré-import de la Recipe {current}/{total} via lextension du navigateur...",
"reimportSuccess": "Recette ré-importée avec succès",
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
"reimportBulkFailed": "Échec du ré-import de certaines Recipes",
+39 -26
View File
@@ -212,20 +212,10 @@
"none": "לכל ה-{typePlural} כבר יש מטא-נתוני רישיון",
"error": "לא ניתן היה לרענן את מטא-נתוני הרישיון עבור {typePlural}: {message}"
},
"repairRecipes": {
"label": "תיקון נתוני מתכונים",
"loading": "מתקן נתוני מתכונים...",
"success": "תוקנו בהצלחה {count} מתכונים.",
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
"error": "תיקון המתכונים נכשל: {message}"
},
"rematchRecipes": {
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
},
@@ -484,6 +474,8 @@
"layoutSettings": {
"groupByModel": "קיבוץ לפי מודל",
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל מודל CivitAI מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
"stickyControls": "השארת סרגל הפעולות גלוי",
"stickyControlsHelp": "כאשר מופעל, סרגל הפעולות (רענון, הורדה וכו') נשאר מוצמד לחלק העליון בעת גלילה, יחד עם ניווט פירורי הלחם.",
"displayDensity": "צפיפות תצוגה",
"displayDensityOptions": {
"default": "ברירת מחדל",
@@ -819,7 +811,6 @@
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
"copyAll": "העתק את כל התחבירים",
"refreshAll": "רענן את כל המטא-נתונים",
"repairMetadata": "תקן מטא-נתונים עבור הנבחרים",
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
"reimportMetadata": "ייבא מחדש ממקור",
"checkUpdates": "בדוק עדכונים לבחירה",
@@ -875,7 +866,6 @@
"replacePreview": "החלף תצוגה מקדימה",
"setContentRating": "הגדר דירוג תוכן",
"moveToFolder": "העבר לתיקייה",
"repairMetadata": "תיקון מטא-נתונים",
"rematchMetadata": "התאמה מחדש למודלים מקומיים",
"reimportMetadata": "ייבא מחדש ממקור",
"excludeModel": "החרג מודל",
@@ -1128,13 +1118,6 @@
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
},
"repair": {
"starting": "מתקן מטא-נתונים של מתכון...",
"success": "מטא-נתונים של מתכון תוקן בהצלחה",
"skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון",
"failed": "תיקון המתכון נכשל: {message}",
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
},
"reimport": {
"starting": "מייבא מתכון מחדש מהמקור...",
"success": "המתכון יובא מחדש בהצלחה",
@@ -1515,6 +1498,41 @@
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
"downloadButton": "הורד {count} LoRA(s)"
},
"rematchOptions": {
"title": "התאמה מחדש של מתכונים",
"messageGlobal": "כל המתכונים ייסרקו מול ספריית המודלים המקומית שלך.",
"messageSingle": "מתכון זה ייסרק מול ספריית המודלים המקומית שלך.",
"messageBulk": "{count} מתכונים שנבחרו ייסרקו מול ספריית המודלים המקומית שלך.",
"relaxedLabel": "חבר מחדש גם מודלים חסרים לפי שם קובץ",
"relaxedDescription": "אפשר לתקן את המודלים האלה גם על ידי הורדה — ההורדה מדויקת יותר. ההתאמות עשויות לקשר לגרסה אחרת; הן יוצגו לסקירה וניתן לבטל אותן.",
"confirmButton": "התאם מחדש"
},
"rematchResults": {
"undo": "בטל",
"undone": "בוטל",
"undoFailed": "ביטול ההתאמה מחדש נכשל: {message}"
},
"rematchSummary": {
"title": "סיכום התאמה מחדש",
"successMessage": "הותאמו {entries} פריטים",
"failed": "ההתאמה מחדש נכשלה",
"completedWithWarnings": "ההתאמה מחדש הושלמה — מומלץ לסקור",
"cancelledNote": "ההתאמה בוטלה לפני שהסתיימה — המספרים חלקיים.",
"statMatched": "פריטים שהותאמו",
"statReview": "טעוני סקירה",
"statUnresolved": "ללא התאמה",
"statErrors": "שגיאות",
"reviewSection": "התאמות לפי שם קובץ לסקירה ({count})",
"columnRecipe": "מתכון",
"columnEntry": "פריט",
"columnFile": "הקובץ שהותאם",
"columnUndo": "בטל",
"copyReport": "העתק דוח",
"close": "סגור",
"scope_global": "כל המתכונים",
"scope_bulk": "מתכונים שנבחרו",
"scope_single": "מתכון בודד"
},
"exampleAccess": {
"title": "תמונות דוגמה מקומיות",
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
@@ -2182,6 +2200,7 @@
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
"created": "המתכון נוצר בהצלחה",
"noMissingLoras": "אין LoRAs חסרים להורדה",
"unresolvableMarkedForReconnect": "{count} פריטים שלא ניתן לפתור סומנו — עכשיו ניתן לחבר אותם מחדש ל-LoRA מקומי.",
"noPreviousRecipe": "אין מתכון קודם זמין",
"noNextRecipe": "אין מתכון נוסף זמין",
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
@@ -2236,16 +2255,10 @@
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
"noRecipesSelected": "לא נבחרו מתכונים",
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
"reimporting": "מייבא מתכון מחדש מהמקור...",
"reimportingViaExtension": "מייבא מתכון מחדש {current}/{total} דרך תוסף הדפדפן...",
"reimportSuccess": "המתכון יובא מחדש בהצלחה",
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
+39 -26
View File
@@ -212,20 +212,10 @@
"none": "すべての{typePlural}には既にライセンスメタデータがあります",
"error": "{typePlural}のライセンスメタデータを更新できませんでした: {message}"
},
"repairRecipes": {
"label": "レシピデータの修復",
"loading": "レシピデータを修復中...",
"success": "{count} 件のレシピを正常に修復しました。",
"cancelled": "修復がキャンセルされました。{count}件のレシピが修復されました。",
"error": "レシピの修復に失敗しました: {message}"
},
"rematchRecipes": {
"label": "レシピをローカルモデルに再マッチング",
"loading": "レシピをローカルモデルに再マッチングしています...",
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
"error": "レシピの再マッチングに失敗しました:{message}"
},
@@ -484,6 +474,8 @@
"layoutSettings": {
"groupByModel": "モデルでグループ化",
"groupByModelHelp": "有効にすると、各CivitAIモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
"stickyControls": "アクションバーを常に表示",
"stickyControlsHelp": "有効にすると、アクションバー(更新、ダウンロードなど)がスクロール時にパンくずナビゲーションと一緒に画面上部に固定されます。",
"displayDensity": "表示密度",
"displayDensityOptions": {
"default": "デフォルト",
@@ -819,7 +811,6 @@
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
"copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新",
"repairMetadata": "選択したレシピのメタデータを修復",
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
"reimportMetadata": "ソースから再インポート",
"checkUpdates": "選択項目の更新を確認",
@@ -875,7 +866,6 @@
"replacePreview": "プレビューを置換",
"setContentRating": "コンテンツレーティングを設定",
"moveToFolder": "フォルダに移動",
"repairMetadata": "メタデータを修復",
"rematchMetadata": "ローカルモデルに再マッチング",
"reimportMetadata": "ソースから再インポート",
"excludeModel": "モデルを除外",
@@ -1128,13 +1118,6 @@
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
},
"repair": {
"starting": "レシピのメタデータを修復中...",
"success": "レシピのメタデータが正常に修復されました",
"skipped": "レシピはすでに最新バージョンです。修復は不要です",
"failed": "レシピの修復に失敗しました: {message}",
"missingId": "レシピを修復できません: レシピIDがありません"
},
"reimport": {
"starting": "ソースからレシピを再インポート中...",
"success": "レシピの再インポートが完了しました",
@@ -1515,6 +1498,41 @@
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
"downloadButton": "{count} 個の LoRA をダウンロード"
},
"rematchOptions": {
"title": "レシピの再マッチング",
"messageGlobal": "すべてのレシピをローカルのモデルライブラリと照合します。",
"messageSingle": "このレシピをローカルのモデルライブラリと照合します。",
"messageBulk": "選択した {count} 件のレシピをローカルのモデルライブラリと照合します。",
"relaxedLabel": "見つからないモデルもファイル名で再接続する",
"relaxedDescription": "これらのモデルはダウンロードでも修正できます(ダウンロードの方が正確です)。マッチにより別バージョンが関連付けられる場合があります。マッチした項目は確認用に一覧表示され、元に戻すことができます。",
"confirmButton": "再マッチング"
},
"rematchResults": {
"undo": "元に戻す",
"undone": "元に戻しました",
"undoFailed": "再マッチングを元に戻せませんでした:{message}"
},
"rematchSummary": {
"title": "再マッチングの概要",
"successMessage": "{entries} エントリをマッチングしました",
"failed": "再マッチングに失敗しました",
"completedWithWarnings": "再マッチングは完了しましたが、要確認の項目があります",
"cancelledNote": "完了前に実行がキャンセルされたため、件数は一部のみです。",
"statMatched": "マッチしたエントリ",
"statReview": "要確認",
"statUnresolved": "マッチなし",
"statErrors": "エラー",
"reviewSection": "確認が必要なファイル名マッチ({count})",
"columnRecipe": "レシピ",
"columnEntry": "エントリ",
"columnFile": "マッチしたファイル",
"columnUndo": "元に戻す",
"copyReport": "レポートをコピー",
"close": "閉じる",
"scope_global": "すべてのレシピ",
"scope_bulk": "選択したレシピ",
"scope_single": "単一のレシピ"
},
"exampleAccess": {
"title": "ローカル例画像",
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
@@ -2182,6 +2200,7 @@
"createMissingData": "レシピ作成に必要なデータが不足しています",
"created": "レシピを作成しました",
"noMissingLoras": "ダウンロードする不足LoRAがありません",
"unresolvableMarkedForReconnect": "解決できないエントリを {count} 件マークしました — ローカルの LoRA に再接続できるようになりました。",
"noPreviousRecipe": "前のレシピがありません",
"noNextRecipe": "次のレシピがありません",
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
@@ -2236,16 +2255,10 @@
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
"noRecipesSelected": "レシピが選択されていません",
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
"reimporting": "ソースからレシピを再インポート中...",
"reimportingViaExtension": "ブラウザ拡張機能経由でレシピを再インポート中 ({current}/{total})...",
"reimportSuccess": "レシピの再インポートが完了しました",
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
+39 -26
View File
@@ -212,20 +212,10 @@
"none": "모든 {typePlural}에 이미 라이선스 메타데이터가 있습니다",
"error": "{typePlural}의 라이선스 메타데이터를 새로고침하지 못했습니다: {message}"
},
"repairRecipes": {
"label": "레시피 데이터 복구",
"loading": "레시피 데이터 복구 중...",
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
"error": "레시피 복구 실패: {message}"
},
"rematchRecipes": {
"label": "레시피를 로컬 모델에 다시 매칭",
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
"error": "레시피 재매칭 실패: {message}"
},
@@ -484,6 +474,8 @@
"layoutSettings": {
"groupByModel": "모델별 그룹화",
"groupByModelHelp": "활성화하면 각 CivitAI 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
"stickyControls": "작업 표시줄 항상 표시",
"stickyControlsHelp": "활성화하면 작업 표시줄(새로고침, 다운로드 등)이 스크롤 시 브레드크럼 내비게이션과 함께 상단에 고정됩니다.",
"displayDensity": "표시 밀도",
"displayDensityOptions": {
"default": "기본",
@@ -819,7 +811,6 @@
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
"copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침",
"repairMetadata": "선택한 레시피 메타데이터 복구",
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
"reimportMetadata": "소스에서 다시 가져오기",
"checkUpdates": "선택 항목 업데이트 확인",
@@ -875,7 +866,6 @@
"replacePreview": "미리보기 교체",
"setContentRating": "콘텐츠 등급 설정",
"moveToFolder": "폴더로 이동",
"repairMetadata": "메타데이터 복구",
"rematchMetadata": "로컬 모델에 다시 매칭",
"reimportMetadata": "소스에서 다시 가져오기",
"excludeModel": "모델 제외",
@@ -1128,13 +1118,6 @@
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
},
"repair": {
"starting": "레시피 메타데이터 복구 중...",
"success": "레시피 메타데이터가 성공적으로 복구되었습니다",
"skipped": "레시피가 이미 최신 버전입니다. 복구가 필요하지 않습니다",
"failed": "레시피 복구 실패: {message}",
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
},
"reimport": {
"starting": "소스에서 레시피를 다시 가져오는 중...",
"success": "레시피를 다시 가져왔습니다",
@@ -1515,6 +1498,41 @@
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
"downloadButton": "{count}개 LoRA 다운로드"
},
"rematchOptions": {
"title": "레시피 재매칭",
"messageGlobal": "모든 레시피를 로컬 모델 라이브러리와 대조하여 검사합니다.",
"messageSingle": "이 레시피를 로컬 모델 라이브러리와 대조하여 검사합니다.",
"messageBulk": "선택한 레시피 {count}개를 로컬 모델 라이브러리와 대조하여 검사합니다.",
"relaxedLabel": "누락된 모델도 파일 이름으로 다시 연결",
"relaxedDescription": "이 모델들은 다운로드로도 해결할 수 있으며 다운로드가 더 정확합니다. 매칭 시 모델의 다른 버전이 연결될 수 있으며, 검토용으로 목록에 표시되고 실행 취소할 수 있습니다.",
"confirmButton": "재매칭"
},
"rematchResults": {
"undo": "실행 취소",
"undone": "실행 취소됨",
"undoFailed": "재매칭 실행 취소 실패: {message}"
},
"rematchSummary": {
"title": "재매칭 요약",
"successMessage": "{entries}개 항목이 매칭되었습니다",
"failed": "재매칭 실패",
"completedWithWarnings": "재매칭이 완료되었습니다 — 검토가 권장됩니다",
"cancelledNote": "완료 전에 실행이 취소되었습니다 — 집계는 부분적입니다.",
"statMatched": "매칭된 항목",
"statReview": "검토 필요",
"statUnresolved": "매칭 없음",
"statErrors": "오류",
"reviewSection": "검토할 파일 이름 매칭 ({count})",
"columnRecipe": "레시피",
"columnEntry": "항목",
"columnFile": "매칭된 파일",
"columnUndo": "실행 취소",
"copyReport": "보고서 복사",
"close": "닫기",
"scope_global": "모든 레시피",
"scope_bulk": "선택한 레시피",
"scope_single": "단일 레시피"
},
"exampleAccess": {
"title": "로컬 예시 이미지",
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
@@ -2182,6 +2200,7 @@
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
"created": "레시피가 생성되었습니다",
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
"unresolvableMarkedForReconnect": "해석할 수 없는 항목 {count}개가 표시되었습니다 — 이제 로컬 LoRA에 다시 연결할 수 있습니다.",
"noPreviousRecipe": "이전 레시피가 없습니다",
"noNextRecipe": "다음 레시피가 없습니다",
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
@@ -2236,16 +2255,10 @@
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
"batchImportDirectorySelected": "선택한 폴더: {path}",
"noRecipesSelected": "선택한 레시피가 없습니다",
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
"reimportingViaExtension": "브라우저 확장 프로그램을 통해 레시피를 다시 가져오는 중 ({current}/{total})...",
"reimportSuccess": "레시피를 다시 가져왔습니다",
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
+39 -26
View File
@@ -212,20 +212,10 @@
"none": "У всех {typePlural} уже есть метаданные лицензии",
"error": "Не удалось обновить метаданные лицензии для {typePlural}: {message}"
},
"repairRecipes": {
"label": "Восстановить данные рецептов",
"loading": "Восстановление данных рецептов...",
"success": "Успешно восстановлено {count} рецептов.",
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
"error": "Ошибка восстановления рецептов: {message}"
},
"rematchRecipes": {
"label": "Повторное сопоставление рецептов с локальными моделями",
"loading": "Повторное сопоставление рецептов с локальными моделями...",
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
"error": "Не удалось выполнить сопоставление рецептов: {message}"
},
@@ -484,6 +474,8 @@
"layoutSettings": {
"groupByModel": "Группировать по модели",
"groupByModelHelp": "При включении отображается только последняя версия каждой модели CivitAI в виде одной карточки. Старые версии скрыты.",
"stickyControls": "Держать панель действий видимой",
"stickyControlsHelp": "При включении панель действий (Обновить, Загрузить и т. д.) остаётся закреплённой вверху при прокрутке вместе с навигацией по папкам.",
"displayDensity": "Плотность отображения",
"displayDensityOptions": {
"default": "По умолчанию",
@@ -819,7 +811,6 @@
"setContentRating": "Установить рейтинг контента для всех",
"copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные",
"repairMetadata": "Восстановить метаданные для выбранных",
"rematchMetadata": "Сопоставить выбранные с локальными моделями",
"reimportMetadata": "Переимпортировать из источника",
"checkUpdates": "Проверить обновления для выбранных",
@@ -875,7 +866,6 @@
"replacePreview": "Заменить превью",
"setContentRating": "Установить рейтинг контента",
"moveToFolder": "Переместить в папку",
"repairMetadata": "Восстановить метаданные",
"rematchMetadata": "Сопоставить с локальными моделями",
"reimportMetadata": "Переимпортировать из источника",
"excludeModel": "Исключить модель",
@@ -1128,13 +1118,6 @@
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
},
"repair": {
"starting": "Восстановление метаданных рецепта...",
"success": "Метаданные рецепта успешно восстановлены",
"skipped": "Рецепт уже последней версии, восстановление не требуется",
"failed": "Не удалось восстановить рецепт: {message}",
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
},
"reimport": {
"starting": "Переимпорт рецепта из источника...",
"success": "Рецепт успешно переимпортирован",
@@ -1515,6 +1498,41 @@
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
"downloadButton": "Скачать {count} LoRA(s)"
},
"rematchOptions": {
"title": "Повторное сопоставление рецептов",
"messageGlobal": "Все рецепты будут проверены по вашей локальной библиотеке моделей.",
"messageSingle": "Этот рецепт будет проверен по вашей локальной библиотеке моделей.",
"messageBulk": "Выбранные рецепты ({count}) будут проверены по вашей локальной библиотеке моделей.",
"relaxedLabel": "Также переподключать отсутствующие модели по имени файла",
"relaxedDescription": "Эти модели также можно исправить загрузкой — загрузка точнее. Совпадения могут привязать другую версию; они будут перечислены для проверки, и их можно будет отменить.",
"confirmButton": "Сопоставить"
},
"rematchResults": {
"undo": "Отменить",
"undone": "Отменено",
"undoFailed": "Не удалось отменить сопоставление: {message}"
},
"rematchSummary": {
"title": "Сводка повторного сопоставления",
"successMessage": "Сопоставлено записей: {entries}",
"failed": "Не удалось выполнить сопоставление",
"completedWithWarnings": "Сопоставление завершено — рекомендуется проверка",
"cancelledNote": "Запуск отменён до завершения — подсчёты неполные.",
"statMatched": "Сопоставленные записи",
"statReview": "Требуют проверки",
"statUnresolved": "Не сопоставлено",
"statErrors": "Ошибки",
"reviewSection": "Совпадения по имени файла для проверки ({count})",
"columnRecipe": "Рецепт",
"columnEntry": "Запись",
"columnFile": "Совпавший файл",
"columnUndo": "Отменить",
"copyReport": "Скопировать отчёт",
"close": "Закрыть",
"scope_global": "Все рецепты",
"scope_bulk": "Выбранные рецепты",
"scope_single": "Один рецепт"
},
"exampleAccess": {
"title": "Локальные примеры изображений",
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
@@ -2182,6 +2200,7 @@
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
"created": "Рецепт успешно создан",
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
"unresolvableMarkedForReconnect": "Помечено неразрешимых записей: {count} — теперь их можно переподключить к локальному LoRA.",
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
"noNextRecipe": "Следующий рецепт отсутствует",
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
@@ -2236,16 +2255,10 @@
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
"batchImportDirectorySelected": "Выбрана папка: {path}",
"noRecipesSelected": "Рецепты не выбраны",
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
"reimporting": "Переимпорт рецепта из источника...",
"reimportingViaExtension": "Переимпорт рецепта {current}/{total} через расширение браузера...",
"reimportSuccess": "Рецепт успешно переимпортирован",
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
+39 -26
View File
@@ -212,20 +212,10 @@
"none": "所有 {typePlural} 都已具备许可证元数据",
"error": "刷新 {typePlural} 的许可证元数据失败:{message}"
},
"repairRecipes": {
"label": "修复配方数据",
"loading": "正在修复配方数据...",
"success": "成功修复了 {count} 个配方。",
"cancelled": "修复已取消。已修复 {count} 个配方。",
"error": "配方修复失败:{message}"
},
"rematchRecipes": {
"label": "将配方重新匹配到本地模型",
"loading": "正在将配方重新匹配到本地模型...",
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
"allFailed": "{failures}/{total} 个配方重新匹配失败",
"noMatch": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
"error": "配方重新匹配失败:{message}"
},
@@ -484,6 +474,8 @@
"layoutSettings": {
"groupByModel": "按模型分组",
"groupByModelHelp": "开启后,每个 CivitAI 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
"stickyControls": "保持操作栏可见",
"stickyControlsHelp": "开启后,操作栏(刷新、下载等)会在滚动时与路径导航一起固定在页面顶部。",
"displayDensity": "显示密度",
"displayDensityOptions": {
"default": "默认",
@@ -819,7 +811,6 @@
"setContentRating": "为所选中设置内容评级",
"copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据",
"repairMetadata": "修复所选中元数据",
"rematchMetadata": "将所选中重新匹配到本地模型",
"reimportMetadata": "从源重新导入",
"checkUpdates": "检查所选更新",
@@ -875,7 +866,6 @@
"replacePreview": "替换预览",
"setContentRating": "设置内容评级",
"moveToFolder": "移动到文件夹",
"repairMetadata": "修复元数据",
"rematchMetadata": "重新匹配到本地模型",
"reimportMetadata": "从源重新导入",
"excludeModel": "排除模型",
@@ -1128,13 +1118,6 @@
"getInfoFailed": "获取缺失 LoRA 信息失败",
"prepareError": "准备下载 LoRA 时出错:{message}"
},
"repair": {
"starting": "正在修复配方元数据...",
"success": "配方元数据修复成功",
"skipped": "配方已是最新版本,无需修复",
"failed": "修复配方失败:{message}",
"missingId": "无法修复配方:缺少配方 ID"
},
"reimport": {
"starting": "正在从源重新导入配方...",
"success": "配方已从源重新导入成功",
@@ -1515,6 +1498,41 @@
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
"downloadButton": "下载 {count} 个 LoRA(s)"
},
"rematchOptions": {
"title": "重新匹配配方",
"messageGlobal": "将对照你的本地模型库扫描所有配方。",
"messageSingle": "将对照你的本地模型库扫描此配方。",
"messageBulk": "将对照你的本地模型库扫描 {count} 个所选配方。",
"relaxedLabel": "同时按文件名重新关联缺失的模型",
"relaxedDescription": "这些模型也可以通过下载来修复——下载更为准确。匹配结果可能链接到模型的其他版本;它们会被列出供检查,且可以撤销。",
"confirmButton": "重新匹配"
},
"rematchResults": {
"undo": "撤销",
"undone": "已撤销",
"undoFailed": "撤销重新匹配失败:{message}"
},
"rematchSummary": {
"title": "重新匹配摘要",
"successMessage": "已匹配 {entries} 个条目",
"failed": "重新匹配失败",
"completedWithWarnings": "重新匹配已完成——建议检查",
"cancelledNote": "运行在完成前已取消——统计不完整。",
"statMatched": "已匹配条目",
"statReview": "需要检查",
"statUnresolved": "未匹配",
"statErrors": "错误",
"reviewSection": "需要检查的文件名匹配({count}",
"columnRecipe": "配方",
"columnEntry": "条目",
"columnFile": "匹配到的文件",
"columnUndo": "撤销",
"copyReport": "复制报告",
"close": "关闭",
"scope_global": "所有配方",
"scope_bulk": "所选配方",
"scope_single": "单个配方"
},
"exampleAccess": {
"title": "本地示例图片",
"message": "未找到此模型的本地示例图片。可选操作:",
@@ -2182,6 +2200,7 @@
"createMissingData": "缺少创建配方所需的数据",
"created": "配方创建成功",
"noMissingLoras": "没有缺失的 LoRA 可下载",
"unresolvableMarkedForReconnect": "已标记 {count} 个无法解析的条目——现在可以将它们重新关联到本地 LoRA。",
"noPreviousRecipe": "没有上一个配方",
"noNextRecipe": "没有下一个配方",
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
@@ -2236,16 +2255,10 @@
"batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方",
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
"repairBulkSkipped": "所选 {total} 个配方无需修复",
"repairBulkFailed": "修复所选配方失败:{message}",
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
"rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败",
"rematchUnmatched": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
"rematchFailed": "重新匹配所选配方失败:{message}",
"reimporting": "正在从源重新导入配方...",
"reimportingViaExtension": "正在通过浏览器扩展重新导入配方 {current}/{total}...",
"reimportSuccess": "配方已从源重新导入成功",
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
"reimportBulkFailed": "重新导入某些配方失败",
+39 -26
View File
@@ -212,20 +212,10 @@
"none": "所有 {typePlural} 已具備授權中繼資料",
"error": "重新整理 {typePlural} 授權中繼資料失敗:{message}"
},
"repairRecipes": {
"label": "修復配方資料",
"loading": "正在修復配方資料...",
"success": "成功修復 {count} 個配方。",
"cancelled": "修復已取消。已修復 {count} 個配方。",
"error": "配方修復失敗:{message}"
},
"rematchRecipes": {
"label": "將配方重新匹配到本地模型",
"loading": "正在將配方重新匹配到本地模型...",
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
"allFailed": "{failures}/{total} 個配方重新匹配失敗",
"noMatch": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
"error": "配方重新匹配失敗:{message}"
},
@@ -484,6 +474,8 @@
"layoutSettings": {
"groupByModel": "按模型分組",
"groupByModelHelp": "啟用後,每個 CivitAI 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
"stickyControls": "保持操作列可見",
"stickyControlsHelp": "啟用後,操作列(重新整理、下載等)會在捲動時與麵包屑導覽一起固定在頁面頂端。",
"displayDensity": "顯示密度",
"displayDensityOptions": {
"default": "預設",
@@ -819,7 +811,6 @@
"setContentRating": "為全部設定內容分級",
"copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata",
"repairMetadata": "修復所選中元數據",
"rematchMetadata": "將所選中重新匹配到本地模型",
"reimportMetadata": "從來源重新匯入",
"checkUpdates": "檢查所選更新",
@@ -875,7 +866,6 @@
"replacePreview": "更換預覽圖",
"setContentRating": "設定內容分級",
"moveToFolder": "移動到資料夾",
"repairMetadata": "修復元數據",
"rematchMetadata": "重新匹配到本地模型",
"reimportMetadata": "從來源重新匯入",
"excludeModel": "排除模型",
@@ -1128,13 +1118,6 @@
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
},
"repair": {
"starting": "正在修復配方元數據...",
"success": "配方元數據修復成功",
"skipped": "配方已是最新版本,無需修復",
"failed": "修復配方失敗:{message}",
"missingId": "無法修復配方:缺少配方 ID"
},
"reimport": {
"starting": "正在從來源重新匯入配方...",
"success": "配方已從來源重新匯入成功",
@@ -1515,6 +1498,41 @@
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
"downloadButton": "下載 {count} 個 LoRA(s)"
},
"rematchOptions": {
"title": "重新匹配配方",
"messageGlobal": "所有配方將對照您的本地模型庫進行掃描。",
"messageSingle": "此配方將對照您的本地模型庫進行掃描。",
"messageBulk": "將對照您的本地模型庫掃描 {count} 個所選配方。",
"relaxedLabel": "同時依檔案名稱重新關聯缺少的模型",
"relaxedDescription": "這些模型也可以透過下載修復——下載更為準確。比對可能會連結到模型的不同版本;比對結果將列出供您檢閱,且可以撤銷。",
"confirmButton": "重新匹配"
},
"rematchResults": {
"undo": "撤銷",
"undone": "已撤銷",
"undoFailed": "撤銷重新匹配失敗:{message}"
},
"rematchSummary": {
"title": "重新匹配摘要",
"successMessage": "已匹配 {entries} 個條目",
"failed": "重新匹配失敗",
"completedWithWarnings": "重新匹配已完成——建議檢查",
"cancelledNote": "執行在完成前已取消——統計不完整。",
"statMatched": "已匹配條目",
"statReview": "需要檢查",
"statUnresolved": "未匹配",
"statErrors": "錯誤",
"reviewSection": "需要檢查的檔案名稱匹配({count})",
"columnRecipe": "配方",
"columnEntry": "條目",
"columnFile": "匹配到的檔案",
"columnUndo": "撤銷",
"copyReport": "複製報告",
"close": "關閉",
"scope_global": "所有配方",
"scope_bulk": "所選配方",
"scope_single": "單個配方"
},
"exampleAccess": {
"title": "本機範例圖片",
"message": "此模型未找到本機範例圖片。可選擇:",
@@ -2182,6 +2200,7 @@
"createMissingData": "缺少建立配方所需的資料",
"created": "配方建立成功",
"noMissingLoras": "無缺少的 LoRA 可下載",
"unresolvableMarkedForReconnect": "已標記 {count} 個無法解析的條目——現在可以將它們重新關聯到本地 LoRA。",
"noPreviousRecipe": "沒有上一個配方",
"noNextRecipe": "沒有下一個配方",
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
@@ -2236,16 +2255,10 @@
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何配方",
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
"repairBulkSkipped": "所選 {total} 個配方無需修復",
"repairBulkFailed": "修復所選配方失敗:{message}",
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
"rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗",
"rematchUnmatched": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
"rematchFailed": "重新匹配所選配方失敗:{message}",
"reimporting": "正在從來源重新匯入配方...",
"reimportingViaExtension": "正在透過瀏覽器擴充功能重新匯入配方 {current}/{total}...",
"reimportSuccess": "配方已從來源重新匯入成功",
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
"reimportBulkFailed": "重新匯入某些配方失敗",
@@ -0,0 +1,59 @@
"""HTTP handler for download target routing decisions."""
from __future__ import annotations
import json
import logging
from aiohttp import web
from ...services.download_routing import is_diffusion_model_download
logger = logging.getLogger(__name__)
class DownloadRoutingHandler:
"""Expose the download-time checkpoint/diffusion-model routing decision.
The web UI calls this when the user reaches the download location step
so the root dropdown offers the same root set (checkpoint vs unet) that
the download manager would pick for ``use_default_paths``.
"""
async def get_download_routing(self, request: web.Request) -> web.Response:
try:
payload = await request.json()
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON payload"}, status=400
)
model_type = payload.get("model_type", "")
base_model = payload.get("base_model") or ""
file_types = payload.get("file_types") or []
if not isinstance(model_type, str) or not model_type:
return web.json_response(
{"success": False, "error": "model_type is required"}, status=400
)
if not isinstance(base_model, str) or not isinstance(file_types, list):
return web.json_response(
{
"success": False,
"error": "base_model must be a string and file_types a list",
},
status=400,
)
is_diffusion = is_diffusion_model_download(
model_type,
file_types=(str(t) for t in file_types),
base_model=base_model,
)
return web.json_response(
{
"success": True,
"is_diffusion_model": is_diffusion,
"root_kind": "unet" if is_diffusion else model_type,
}
)
+1 -5
View File
@@ -122,12 +122,8 @@ async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
metadata._unknown_fields["hf_url"] = hf_url
metadata.from_civitai = False # HF models are not from CivitAI
metadata_dict = metadata.to_dict()
if "trainedWords" in metadata_dict and not metadata_dict["trainedWords"]:
del metadata_dict["trainedWords"]
# 3. Save metadata atomically
await MetadataManager.save_metadata(dest_path, metadata_dict)
await MetadataManager.save_metadata(dest_path, metadata)
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
# 4. Determine relative folder path for cache
+5
View File
@@ -56,6 +56,7 @@ from ...utils.constants import (
)
from .hf_handlers import HfHandler
from .agent_handlers import AgentHandler
from .download_routing_handlers import DownloadRoutingHandler
from .model_handlers import ModelCivitaiHandler
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import (
@@ -3884,6 +3885,7 @@ class MiscHandlerSet:
base_model: BaseModelHandlerSet,
hf_handler: Any = None,
agent_handler: Any = None,
download_routing: Any = None,
) -> None:
self.health = health
self.settings = settings
@@ -3904,6 +3906,7 @@ class MiscHandlerSet:
self.base_model = base_model
self.hf_handler = hf_handler
self.agent_handler = agent_handler
self.download_routing = download_routing
def to_route_mapping(
self,
@@ -3962,6 +3965,8 @@ class MiscHandlerSet:
"get_agent_skills": self.agent_handler.get_agent_skills,
"execute_agent_skill": self.agent_handler.execute_agent_skill,
"cancel_agent_skill": self.agent_handler.cancel_agent_skill,
# Download routing handler
"get_download_routing": self.download_routing.get_download_routing,
# Base model handlers
"get_base_models": self.base_model.get_base_models,
"refresh_base_models": self.base_model.refresh_base_models,
+235 -238
View File
@@ -74,6 +74,26 @@ async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
async def _parse_relaxed_flag(request: web.Request) -> bool:
"""Read the relaxed-rematch flag from the JSON body or query string.
The flag defaults to False (strict candidacy). A JSON body value wins;
``?relaxed=true`` is honored as a fallback so GET-only clients can opt
in. Body parse failures (empty/invalid JSON) are treated as "no flag".
"""
relaxed = False
if request.can_read_body:
try:
data = await request.json()
except Exception: # noqa: BLE001 - any parse failure means no flag
data = None
if isinstance(data, dict):
relaxed = bool(data.get("relaxed"))
if not relaxed:
relaxed = request.query.get("relaxed", "").lower() == "true"
return relaxed
@dataclass(frozen=True)
class RecipeHandlerSet:
"""Group of handlers providing recipe route implementations."""
@@ -129,11 +149,6 @@ class RecipeHandlerSet:
"get_recipes_for_checkpoint": self.query.get_recipes_for_checkpoint,
"scan_recipes": self.query.scan_recipes,
"move_recipe": self.management.move_recipe,
"repair_recipes": self.management.repair_recipes,
"cancel_repair": self.management.cancel_repair,
"repair_recipe": self.management.repair_recipe,
"repair_recipes_bulk": self.management.repair_recipes_bulk,
"get_repair_progress": self.management.get_repair_progress,
"rematch_recipes": self.management.rematch_recipes,
"cancel_rematch": self.management.cancel_rematch,
"rematch_recipe": self.management.rematch_recipe,
@@ -796,157 +811,6 @@ class RecipeManagementHandler:
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500)
async def repair_recipes(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
# Check if already running
if self._ws_manager.is_recipe_repair_running():
return web.json_response(
{"success": False, "error": "Recipe repair already in progress"},
status=409,
)
recipe_scanner.reset_cancellation()
async def progress_callback(data):
await self._ws_manager.broadcast_recipe_repair_progress(data)
# Run in background to avoid timeout
async def run_repair():
try:
await recipe_scanner.repair_all_recipes(
progress_callback=progress_callback
)
except Exception as e:
self._logger.error(
f"Error in recipe repair task: {e}", exc_info=True
)
await self._ws_manager.broadcast_recipe_repair_progress(
{"status": "error", "error": str(e)}
)
finally:
# Keep the final status for a while so the UI can see it
await asyncio.sleep(5)
# Don't cleanup if it was cancelled, let the UI see the cancelled state for a bit?
# Actually cleanup_recipe_repair_progress is fine as long as we waited enough.
self._ws_manager.cleanup_recipe_repair_progress()
asyncio.create_task(run_repair())
return web.json_response(
{"success": True, "message": "Recipe repair started"}
)
except Exception as exc:
self._logger.error("Error starting recipe repair: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def cancel_repair(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
recipe_scanner.cancel_task()
return web.json_response(
{"success": True, "message": "Cancellation requested"}
)
except Exception as exc:
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def repair_recipes_bulk(self, request: web.Request) -> web.Response:
"""Bulk repair metadata for multiple recipes by their IDs.
Accepts a JSON body with a "recipe_ids" array and iterates
repair_recipe_by_id over each entry, collecting statistics.
"""
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
data = await request.json()
recipe_ids = data.get("recipe_ids", [])
if not recipe_ids:
return web.json_response(
{"success": False, "error": "recipe_ids are required"},
status=400,
)
total = len(recipe_ids)
repaired = 0
skipped = 0
errors = 0
recipes = []
for recipe_id in recipe_ids:
try:
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
if result.get("success"):
repaired += result.get("repaired", 0)
skipped += result.get("skipped", 0)
if result.get("recipe"):
recipes.append(result["recipe"])
else:
errors += 1
except RecipeNotFoundError:
skipped += 1
except Exception as exc:
self._logger.error(
"Error repairing recipe %s: %s", recipe_id, exc
)
errors += 1
return web.json_response({
"success": True,
"total": total,
"repaired": repaired,
"skipped": skipped,
"errors": errors,
"recipes": recipes,
})
except Exception as exc:
self._logger.error(
"Error performing bulk repair: %s", exc, exc_info=True
)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
async def repair_recipe(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
recipe_id = request.match_info["recipe_id"]
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
return web.json_response(result)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
except Exception as exc:
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def rematch_recipes(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
@@ -958,12 +822,9 @@ class RecipeManagementHandler:
)
# Mutual exclusion: a global rematch cannot start while a rematch
# OR a repair is already running — both mutate recipes under the
# same mutation lock.
if (
self._ws_manager.is_recipe_rematch_running()
or self._ws_manager.is_recipe_repair_running()
):
# is already running — both mutate recipes under the same
# mutation lock.
if self._ws_manager.is_recipe_rematch_running():
return web.json_response(
{"success": False, "error": "Recipe rematch already in progress"},
status=409,
@@ -971,6 +832,8 @@ class RecipeManagementHandler:
recipe_scanner.reset_cancellation()
relaxed = await _parse_relaxed_flag(request)
async def progress_callback(data):
await self._ws_manager.broadcast_recipe_rematch_progress(data)
@@ -978,7 +841,8 @@ class RecipeManagementHandler:
async def run_rematch():
try:
await recipe_scanner.rematch_all_recipes(
progress_callback=progress_callback
progress_callback=progress_callback,
relaxed=relaxed,
)
except Exception as e:
self._logger.error(
@@ -1051,7 +915,13 @@ class RecipeManagementHandler:
status=400,
)
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids)
relaxed = bool(data.get("relaxed")) or (
request.query.get("relaxed", "").lower() == "true"
)
result = await recipe_scanner.rematch_recipes_bulk(
recipe_ids, relaxed=relaxed
)
return web.json_response(result)
except Exception as exc:
self._logger.error(
@@ -1080,7 +950,10 @@ class RecipeManagementHandler:
)
recipe_id = request.match_info["recipe_id"]
result = await recipe_scanner.rematch_recipe_by_id(recipe_id)
relaxed = await _parse_relaxed_flag(request)
result = await recipe_scanner.rematch_recipe_by_id(
recipe_id, relaxed=relaxed
)
return web.json_response(result)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
@@ -1179,12 +1052,55 @@ class RecipeManagementHandler:
persisted_source_path=persisted_source_path,
)
async with self._import_semaphore:
import_response = await self._do_import_from_url(
source_path,
recipe_scanner,
target_dir=old_folder,
)
# Optional caller-supplied metadata payload (companion browser
# extension re-import). Only honored for CivitAI image page
# sources; everything else uses the native URL import below.
params = request.rel_url.query
payload_image_url = params.get("image_url")
payload_name = params.get("name")
payload_resources = params.get("resources")
has_import_payload = bool(
payload_image_url and payload_name and payload_resources
)
import_response: web.Response | None = None
if has_import_payload and image_id:
try:
async with self._import_semaphore:
import_response = await self._import_remote_recipe_impl(
image_url=payload_image_url,
name=payload_name,
resources_raw=payload_resources,
gen_params_raw=params.get("gen_params"),
tags_raw=params.get("tags"),
base_model=params.get("base_model", "") or "",
source_path=source_path,
target_dir=old_folder,
)
except RecipeValidationError as exc:
# Malformed resources/gen_params JSON: treat as "no
# payload" and use the legacy URL re-import.
self._logger.warning(
"Ignoring malformed re-import payload for recipe %s "
"(%s); falling back to source URL re-import",
recipe_id,
exc,
)
except Exception as exc:
self._logger.warning(
"Payload-based re-import failed for recipe %s: %s; "
"falling back to source URL re-import",
recipe_id,
exc,
)
if import_response is None:
async with self._import_semaphore:
import_response = await self._do_import_from_url(
source_path,
recipe_scanner,
target_dir=old_folder,
)
await self._persistence_service.delete_recipe(
recipe_scanner=recipe_scanner, recipe_id=recipe_id
@@ -1211,14 +1127,19 @@ class RecipeManagementHandler:
exc,
)
return web.json_response(
{
"success": True,
"old_recipe_id": recipe_id,
"recipe_id": new_recipe_id,
"source_path": source_path,
}
response_body: Dict[str, Any] = {
"success": True,
"old_recipe_id": recipe_id,
"recipe_id": new_recipe_id,
"source_path": source_path,
}
loras_count = await self._count_recipe_loras(
recipe_scanner, new_recipe_id
)
if loras_count is not None:
response_body["loras_count"] = loras_count
return web.json_response(response_body)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
except RecipeValidationError as exc:
@@ -1231,18 +1152,6 @@ class RecipeManagementHandler:
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_repair_progress(self, request: web.Request) -> web.Response:
try:
progress = self._ws_manager.get_recipe_repair_progress()
if progress:
return web.json_response({"success": True, "progress": progress})
return web.json_response(
{"success": False, "message": "No repair in progress"}, status=404
)
except Exception as exc:
self._logger.error("Error getting repair progress: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def import_remote_recipe(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
@@ -1263,31 +1172,14 @@ class RecipeManagementHandler:
if not resources_raw:
raise RecipeValidationError("Missing required field: resources")
checkpoint_entry, lora_entries = self._parse_resources_payload(
resources_raw
)
gen_params_request = self._parse_gen_params(params.get("gen_params"))
self._logger.info(
"Remote recipe import received: url=%s, lora_count=%d",
image_url,
len(lora_entries),
)
self._logger.debug(
" gen_params_keys=%s, checkpoint_keys=%s",
sorted(gen_params_request.keys()) if gen_params_request else [],
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
)
# Throttle concurrent imports to avoid starving ComfyUI's event loop
async with self._import_semaphore:
return await self._do_import_remote_recipe(
return await self._import_remote_recipe_impl(
image_url=image_url,
name=name,
lora_entries=lora_entries,
checkpoint_entry=checkpoint_entry,
gen_params_request=gen_params_request,
tags=self._parse_tags(params.get("tags")),
resources_raw=resources_raw,
gen_params_raw=params.get("gen_params"),
tags_raw=params.get("tags"),
base_model=params.get("base_model", "") or "",
source_path=params.get("source_path") or image_url,
)
@@ -1301,6 +1193,52 @@ class RecipeManagementHandler:
)
return web.json_response({"error": str(exc)}, status=500)
async def _import_remote_recipe_impl(
self,
*,
image_url: str,
name: str,
resources_raw: str,
gen_params_raw: Optional[str],
tags_raw: Optional[str],
base_model: str,
source_path: str,
target_dir: str | None = None,
) -> web.Response:
"""Payload-based remote import engine shared by import-remote and the
extension-driven re-import path.
Parses the caller-supplied payloads and delegates to
:meth:`_do_import_remote_recipe`. Raises ``RecipeValidationError`` on
malformed payloads so callers can decide how to handle them (the
re-import path falls back to the legacy URL import).
"""
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
gen_params_request = self._parse_gen_params(gen_params_raw)
self._logger.info(
"Remote recipe import received: url=%s, lora_count=%d",
image_url,
len(lora_entries),
)
self._logger.debug(
" gen_params_keys=%s, checkpoint_keys=%s",
sorted(gen_params_request.keys()) if gen_params_request else [],
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
)
return await self._do_import_remote_recipe(
image_url=image_url,
name=name,
lora_entries=lora_entries,
checkpoint_entry=checkpoint_entry,
gen_params_request=gen_params_request,
tags=self._parse_tags(tags_raw),
base_model=base_model,
source_path=source_path,
target_dir=target_dir,
)
async def _do_import_remote_recipe(
self,
*,
@@ -1312,6 +1250,7 @@ class RecipeManagementHandler:
tags: list[Any],
base_model: str,
source_path: str,
target_dir: str | None = None,
) -> web.Response:
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
@@ -1475,6 +1414,7 @@ class RecipeManagementHandler:
tags=tags,
metadata=metadata,
extension=extension,
target_dir=target_dir,
)
return web.json_response(result.payload, status=result.status)
@@ -1939,6 +1879,25 @@ class RecipeManagementHandler:
return []
return [tag.strip() for tag in tag_text.split(",") if tag.strip()]
async def _count_recipe_loras(
self, recipe_scanner: Any, recipe_id: Optional[str]
) -> Optional[int]:
"""Best-effort LoRA count for a freshly saved recipe (for the
re-import response). Returns None when the recipe cannot be read."""
if not recipe_id:
return None
try:
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
except Exception as exc:
self._logger.debug(
"Could not read new recipe %s for loras_count: %s",
recipe_id,
exc,
)
return None
loras = (recipe or {}).get("loras")
return len(loras) if isinstance(loras, list) else None
def _parse_gen_params(self, payload: Optional[str]) -> Optional[Dict[str, Any]]:
if payload is None:
return None
@@ -3165,6 +3124,12 @@ class RecipeWorkflowHandler:
class BatchImportHandler:
"""Handle batch import operations for recipes."""
# Virtual path token for the Windows drive list. Browsing up from a drive
# root (e.g. C:\) lands here so users can switch drives without typing a
# path. Only meaningful on Windows; elsewhere it falls through to normal
# path handling and fails the existence check.
WINDOWS_DRIVES_TOKEN = "__drives__"
def __init__(
self,
*,
@@ -3338,31 +3303,27 @@ class BatchImportHandler:
data = await request.json()
directory_path = data.get("path", "")
if os.name == "nt" and directory_path == self.WINDOWS_DRIVES_TOKEN:
return self._windows_drives_response()
# Default to the user's home directory. The frontend previously
# sent "/" as the initial path, which is POSIX-only: on Windows it
# resolves to the current drive root and then fails the access
# check below.
if not directory_path:
return web.json_response(
{"success": False, "error": "Directory path is required"},
status=400,
)
path = Path.home()
else:
path = Path(directory_path).expanduser().resolve()
# Normalize the path
path = Path(directory_path).expanduser().resolve()
# Security check: ensure path is within allowed directories
# Allow common image/model directories
allowed_roots = [
Path.home(),
Path("/"), # Allow browsing from root for flexibility
]
# Check if path is within any allowed root
is_allowed = False
for root in allowed_roots:
try:
path.relative_to(root)
is_allowed = True
break
except ValueError:
continue
# Access check: browsing intentionally covers the whole server
# filesystem (the server operator browses their own machine). On
# POSIX every absolute path is under "/", but Path("/") has no
# drive letter on Windows and can never anchor a drive-qualified
# path in relative_to(), so test for a drive there instead.
if os.name == "nt":
is_allowed = bool(path.drive)
else:
is_allowed = path.is_absolute()
if not is_allowed:
return web.json_response(
@@ -3429,15 +3390,24 @@ class BatchImportHandler:
directories.sort(key=lambda x: x["name"].lower())
image_files.sort(key=lambda x: x["name"].lower())
# Add parent directory if not at root
parent_path = path.parent
show_parent = str(path) != str(path.root)
# Parent directory. A filesystem root is its own parent
# (parent == path): POSIX "/" gets no parent, while a Windows
# drive root (C:\) links up to the virtual drive list so users
# can switch drives. The previous str(path) != str(path.root)
# check misfired on Windows, where a drive root's parent is
# itself, producing an infinite self-loop.
if path.parent == path:
parent_path = (
self.WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
)
else:
parent_path = str(path.parent)
return web.json_response(
{
"success": True,
"current_path": str(path),
"parent_path": str(parent_path) if show_parent else None,
"parent_path": parent_path,
"directories": directories,
"image_files": image_files,
"image_count": len(image_files),
@@ -3464,3 +3434,30 @@ class BatchImportHandler:
except Exception as exc:
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
def _windows_drives_response(self) -> web.Response:
"""List available drive letters as a virtual directory (Windows only)."""
try:
drives = os.listdrives()
except AttributeError: # Python < 3.12
drives = [
f"{letter}:\\"
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if os.path.exists(f"{letter}:\\")
]
directories = [
{"name": drive, "path": drive, "is_parent": False} for drive in drives
]
return web.json_response(
{
"success": True,
# Empty current_path marks the virtual level; the frontend
# disables folder selection there.
"current_path": "",
"parent_path": None,
"directories": directories,
"image_files": [],
"image_count": 0,
"directory_count": len(directories),
}
)
+4
View File
@@ -103,6 +103,10 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
),
# Download target routing decision (checkpoint vs diffusion model roots)
RouteDefinition(
"POST", "/api/lm/download/routing", "get_download_routing"
),
RouteDefinition(
"POST", "/api/lm/download-hf-model", "download_hf_model"
),
+3
View File
@@ -41,6 +41,7 @@ from .handlers.misc_handlers import (
from .handlers.base_model_handlers import BaseModelHandlerSet
from .handlers.hf_handlers import HfHandler
from .handlers.agent_handlers import AgentHandler
from .handlers.download_routing_handlers import DownloadRoutingHandler
from .misc_route_registrar import MiscRouteRegistrar
logger = logging.getLogger(__name__)
@@ -140,6 +141,7 @@ class MiscRoutes:
base_model = BaseModelHandlerSet()
hf_handler = HfHandler()
agent_handler = AgentHandler()
download_routing = DownloadRoutingHandler()
return self._handler_set_factory(
health=health,
@@ -161,6 +163,7 @@ class MiscRoutes:
base_model=base_model,
hf_handler=hf_handler,
agent_handler=agent_handler,
download_routing=download_routing,
)
+5 -5
View File
@@ -84,11 +84,6 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"GET", "/api/lm/recipes/for-checkpoint", "get_recipes_for_checkpoint"
),
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
RouteDefinition("POST", "/api/lm/recipes/rematch", "rematch_recipes"),
RouteDefinition("POST", "/api/lm/recipes/rematch-bulk", "rematch_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/rematch", "rematch_recipe"),
@@ -115,6 +110,11 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
),
# The companion browser extension only ever issues GET requests, so the
# payload-based re-import variant must also be reachable via GET.
RouteDefinition(
"GET", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
),
RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow"
),
-1
View File
@@ -407,7 +407,6 @@ class AgentService:
"base_model": metadata.get("base_model", ""),
"tags": metadata.get("tags", []),
"modelDescription": metadata.get("modelDescription", ""),
"trainedWords": metadata.get("trainedWords", []),
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
"size": metadata.get("size", 0),
}
+81 -11
View File
@@ -161,6 +161,11 @@ class Aria2Downloader:
(typically an expired CivitAI signed URL): a fresh URL is resolved
and the partial download continues. Recovery is bounded by
``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
Cancellation never leaks daemon transfers: the gid is tracked in
``_transfers`` before any post-``addUri`` await, and a gid accepted
by the daemon while the caller is being cancelled is removed again
before the ``CancelledError`` propagates.
"""
await self._ensure_process()
@@ -251,7 +256,11 @@ class Aria2Downloader:
await asyncio.sleep(self._poll_interval)
finally:
current = self._transfers.get(download_id)
if current is not None and current.gid == transfer.gid:
if (
transfer is not None
and current is not None
and current.gid == transfer.gid
):
self._transfers.pop(download_id, None)
async def _get_status_with_retry(
@@ -339,21 +348,43 @@ class Aria2Downloader:
resolved_url != url,
)
# Shield the addUri RPC from cancellation: the daemon may accept the
# download even when the caller is cancelled while the request is in
# flight. On cancellation, wait for the RPC result so the freshly
# created gid can be removed instead of leaking an untracked
# download that keeps running in the daemon.
add_task = asyncio.ensure_future(
self._rpc_call("aria2.addUri", [[resolved_url], options])
)
try:
gid = await self._rpc_call("aria2.addUri", [[resolved_url], options])
gid = await asyncio.shield(add_task)
except asyncio.CancelledError:
leaked_gid: Any = None
try:
leaked_gid = await add_task
except Exception:
leaked_gid = None
if isinstance(leaked_gid, str) and leaked_gid:
logger.info(
"Removing aria2 gid %s accepted while download %s was "
"being cancelled",
leaked_gid,
download_id,
)
try:
await self._rpc_call("aria2.forceRemove", [leaked_gid])
except Exception as exc:
logger.warning(
"Failed to remove leaked aria2 gid %s for download %s: %s",
leaked_gid,
download_id,
exc,
)
raise
except Exception as exc:
raise Aria2Error(f"Failed to schedule aria2 download: {exc}") from exc
logger.debug("aria2 accepted download %s with gid %s", download_id, gid)
await self._state_store.upsert(
download_id,
{
"gid": gid,
"save_path": save_path,
"status": "downloading",
"url": url,
},
)
return gid
async def _register_transfer(
@@ -372,7 +403,46 @@ class Aria2Downloader:
headers=headers,
)
transfer = Aria2Transfer(gid=gid, save_path=os.path.abspath(save_path))
# Register the transfer before any further await: once the daemon
# holds the gid, cancel_download() must be able to find it. An await
# in between would open a window where a concurrent cancel reports
# "Download task not found" and the daemon keeps downloading
# untracked.
self._transfers[download_id] = transfer
try:
await self._state_store.upsert(
download_id,
{
"gid": gid,
"save_path": transfer.save_path,
"status": "downloading",
"url": url,
},
)
except asyncio.CancelledError:
# The task was cancelled while persisting state and the
# coordinator's cancel ran before the transfer was registered
# above. Remove the daemon transfer unless it was deliberately
# paused (skip_download preserves paused transfers for resume).
status = None
try:
status = await self.get_status(download_id)
except Exception:
status = None
if status is not None and status.get("status") != "paused":
try:
await self._rpc_call("aria2.forceRemove", [gid])
except Exception as exc:
logger.warning(
"Failed to remove aria2 gid %s for cancelled download %s: %s",
gid,
download_id,
exc,
)
current = self._transfers.get(download_id)
if current is not None and current.gid == gid:
self._transfers.pop(download_id, None)
raise
return transfer
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
+5 -3
View File
@@ -410,6 +410,10 @@ class CheckpointScanner(ModelScanner):
return None
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
"""Resolve sub_type from the configured root that contains the file."""
return self._resolve_sub_type(self._find_root_for_file(file_path))
def adjust_metadata(self, metadata, file_path, root_path):
"""Adjust metadata during scanning to set sub_type."""
sub_type = self._resolve_sub_type(root_path)
@@ -419,9 +423,7 @@ class CheckpointScanner(ModelScanner):
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
"""Adjust entries loaded from the persisted cache to ensure sub_type is set."""
sub_type = self._resolve_sub_type(
self._find_root_for_file(entry.get("file_path"))
)
sub_type = self.resolve_sub_type_for_path(entry.get("file_path"))
if sub_type:
entry["sub_type"] = sub_type
return entry
+44
View File
@@ -505,6 +505,50 @@ class CivitaiClient:
logger.warning(f"Failed to fetch version by id {version_id}")
return None
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
"""Fetch raw stored file info via the model-versions/mini endpoint.
The public REST API rewrites ``files[].name`` to
``"{model}_{version}"`` for non-LoRA model types, so every
precision variant of a multi-file version shares one name (#1100).
The mini endpoint returns the raw ``ModelFile.name`` in
``fileName``. ``file_id`` is mandatory: without it mini picks a
file via its own primary-file logic, which can disagree with the
REST ``primary`` flag.
Returns the mini payload dict on success, None on any failure.
"""
try:
success, data = await self._make_request(
"GET",
f"{self.base_url}/model-versions/mini/{version_id}",
params={"modelFileId": file_id},
use_auth=True,
)
if success and isinstance(data, dict):
return data
if is_expected_offline_error(data):
return None
logger.debug(
"Mini endpoint lookup failed for version %s file %s: %s",
version_id,
file_id,
data,
)
return None
except RateLimitError:
raise
except Exception as exc:
logger.debug(
"Error fetching mini info for version %s file %s: %s",
version_id,
file_id,
exc,
)
return None
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict[str, Any]]:
if not model_hash:
return None
+63 -22
View File
@@ -20,7 +20,6 @@ from urllib.parse import urlparse
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.constants import (
CARD_PREVIEW_WIDTH,
DIFFUSION_MODEL_BASE_MODELS,
MODEL_WEIGHT_FILE_TYPES,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES,
@@ -32,9 +31,11 @@ from ..utils.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager
from .service_registry import ServiceRegistry
from .download_routing import is_diffusion_model_download
from .settings_manager import get_settings_manager
from .metadata_service import get_default_metadata_provider, get_metadata_provider
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
from .errors import RateLimitError
from .aria2_downloader import Aria2Error, get_aria2_downloader
from .aria2_transfer_state import Aria2TransferStateStore
from .download_queue_service import DownloadQueueService
@@ -929,6 +930,42 @@ class DownloadManager:
return download_urls
async def _fetch_raw_file_name(
self,
metadata_provider,
version_id: Optional[int],
file_id: Any,
) -> Optional[str]:
"""Best-effort lookup of the raw stored filename via the CivitAI
model-versions/mini endpoint (#1100). Returns None on any failure so
the caller can fall back to the (possibly rewritten) REST name."""
if version_id is None or file_id is None:
return None
fetch = getattr(metadata_provider, "get_version_file_mini", None)
if fetch is None:
return None
try:
mini_info = await fetch(int(version_id), int(file_id))
except (TypeError, ValueError):
return None
except RateLimitError:
raise
except Exception as exc:
logger.debug(
"Mini endpoint lookup failed for version %s file %s: %s",
version_id,
file_id,
exc,
)
return None
if not isinstance(mini_info, dict):
return None
raw_name = mini_info.get("fileName")
if not isinstance(raw_name, str) or not raw_name.strip():
return None
# Defensive: never let a path component slip into the filename.
return os.path.basename(raw_name.strip()) or None
def _build_metadata_for_resume(
self,
*,
@@ -1584,27 +1621,13 @@ class DownloadManager:
}
# Check if this checkpoint should be treated as a diffusion model
# Priority: (1) any file has type "UNet" or "Diffusion Model",
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
is_diffusion_model = False
if model_type == "checkpoint":
# Check file types first (more direct signal from CivitAI)
version_files = version_info.get("files", [])
for f in version_files:
f_type = f.get("type", "")
if f_type in ("UNet", "Diffusion Model"):
is_diffusion_model = True
logger.info(
f"File type '{f_type}' detected, routing checkpoint to unet folder"
)
break
# Fallback to baseModel name check
if not is_diffusion_model and base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True
logger.info(
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
)
# (shared with the download routing endpoint so the UI location
# step and the actual download agree on the target roots).
is_diffusion_model = is_diffusion_model_download(
model_type,
file_types=(f.get("type", "") for f in version_info.get("files", [])),
base_model=base_model_value,
)
# Existence check after the metadata fetch (#1058):
# - An explicit file selection only blocks when THIS file is
@@ -1858,6 +1881,24 @@ class DownloadManager:
if not download_urls:
return {"success": False, "error": "No mirror URL found"}
# The public REST API rewrites files[].name to
# "{model}_{version}" for non-LoRA model types, so every
# precision variant of a multi-file version shares one name and
# lands on disk with a random short-hash suffix. The mini
# endpoint returns the raw stored filename (#1100). CivArchive
# already serves raw names.
if source != "civarchive":
raw_file_name = await self._fetch_raw_file_name(
metadata_provider, resolved_version_id, file_info.get("id")
)
if raw_file_name and raw_file_name != file_info.get("name"):
logger.info(
"[download] Using raw stored filename '%s' instead of REST name '%s'",
raw_file_name,
file_info.get("name"),
)
file_info = {**file_info, "name": raw_file_name}
# 3. Prepare download
file_name = file_info.get("name", "")
if not file_name:
+53
View File
@@ -0,0 +1,53 @@
"""Shared download routing logic.
Decides whether a download initiated from the checkpoint library should be
routed to the unet/diffusion-model roots instead of the checkpoint roots.
Used by both the download manager (at download time) and the download
routing HTTP endpoint (when the user picks a location in the UI), so the
two can never disagree.
"""
from __future__ import annotations
import logging
from typing import Iterable
from ..utils.constants import DIFFUSION_MODEL_BASE_MODELS
logger = logging.getLogger(__name__)
# File types reported by the CivitAI API that indicate a raw diffusion
# model (loaded via UNETLoader in ComfyUI) rather than a full checkpoint.
DIFFUSION_FILE_TYPES = frozenset({"UNet", "Diffusion Model"})
def is_diffusion_model_download(
model_type: str,
file_types: Iterable[str] = (),
base_model: str = "",
) -> bool:
"""Return True when a download should be routed to the unet roots.
Only applies to downloads initiated from the checkpoint library.
Priority: (1) any file has type "UNet" or "Diffusion Model" (the more
direct signal from CivitAI), (2) baseModel is a known diffusion model.
"""
if model_type != "checkpoint":
return False
for file_type in file_types:
if file_type in DIFFUSION_FILE_TYPES:
logger.info(
"File type '%s' detected, routing checkpoint to unet folder",
file_type,
)
return True
if base_model in DIFFUSION_MODEL_BASE_MODELS:
logger.info(
"baseModel '%s' is a known diffusion model, routing to unet folder",
base_model,
)
return True
return False
+92 -49
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Any, Dict, List, Optional
import aiohttp
@@ -32,8 +33,26 @@ _catalog_cache: Optional[Dict[str, List[str]]] = None
# ``{provider_id: {model_id: max_output_tokens}}``.
_model_output_limits: Dict[str, Dict[str, int]] = {}
# Monotonic timestamp of the last failed catalog fetch (None = no failure
# yet). Failed fetches are negatively cached: further calls return the
# empty fallback without hitting the network until the cooldown elapses,
# so users on broken networks don't stall on every settings-modal open.
_catalog_last_failure: Optional[float] = None
_CATALOG_FAILURE_COOLDOWN = 600.0 # seconds
# Serializes catalog fetches so concurrent callers don't duplicate requests.
_catalog_lock = asyncio.Lock()
_CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30)
# Cloudflare serves brotli when the client advertises it, and brotli is a
# required dependency here — a corrupted br stream can crash the native
# decoder with a Windows access violation (issue #1099). Request gzip
# instead; zlib decompression is not affected and corrupt gzip data only
# raises ContentEncodingError (an aiohttp.ClientError subclass), which the
# exception handlers below already catch.
_NO_BROTLI_HEADERS = {"Accept-Encoding": "gzip, deflate"}
async def _load_model_catalog() -> Dict[str, List[str]]:
"""Fetch and parse the model catalog.
@@ -46,61 +65,85 @@ async def _load_model_catalog() -> Dict[str, List[str]]:
value has a ``models`` sub-dict keyed by model ID. The result is cached
in memory after the first successful fetch.
Subsequent calls return the cached data immediately.
Failed fetches are negatively cached: further calls return an empty
dict without hitting the network until ``_CATALOG_FAILURE_COOLDOWN``
has elapsed, so a broken network does not stall every settings-modal
open. Concurrent callers are serialized behind :data:`_catalog_lock`
so only one request is ever in flight.
"""
global _catalog_cache, _model_output_limits
global _catalog_cache, _model_output_limits, _catalog_last_failure
if _catalog_cache is not None:
return _catalog_cache
try:
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
async with session.get(_MODEL_CATALOG_URL) as resp:
if resp.status != 200:
logger.warning("Model catalog returned HTTP %s", resp.status)
return _catalog_cache or {}
data = await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc:
logger.warning("Failed to fetch model catalog: %s", exc)
return _catalog_cache or {}
async with _catalog_lock:
# Re-check under the lock: another caller may have fetched (or
# failed) while we were waiting.
if _catalog_cache is not None:
return _catalog_cache
if (
_catalog_last_failure is not None
and time.monotonic() - _catalog_last_failure < _CATALOG_FAILURE_COOLDOWN
):
logger.debug(
"Skipping model catalog fetch: last attempt failed %.0fs ago",
time.monotonic() - _catalog_last_failure,
)
return {}
if not isinstance(data, dict):
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
return _catalog_cache or {}
try:
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
async with session.get(_MODEL_CATALOG_URL, headers=_NO_BROTLI_HEADERS) as resp:
if resp.status != 200:
logger.warning("Model catalog returned HTTP %s", resp.status)
_catalog_last_failure = time.monotonic()
return {}
data = await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.warning("Failed to fetch model catalog: %s", exc)
_catalog_last_failure = time.monotonic()
return {}
result: Dict[str, List[str]] = {}
output_limits: Dict[str, Dict[str, int]] = {}
for provider_id, provider_info in data.items():
if not isinstance(provider_info, dict):
continue
models_dict = provider_info.get("models")
if not isinstance(models_dict, dict):
continue
model_ids: List[str] = []
provider_limits: Dict[str, int] = {}
for mid, model_info in models_dict.items():
if not isinstance(mid, str):
if not isinstance(data, dict):
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
_catalog_last_failure = time.monotonic()
return {}
result: Dict[str, List[str]] = {}
output_limits: Dict[str, Dict[str, int]] = {}
for provider_id, provider_info in data.items():
if not isinstance(provider_info, dict):
continue
model_ids.append(mid)
if isinstance(model_info, dict):
limit = model_info.get("limit")
if isinstance(limit, dict):
output = limit.get("output")
if isinstance(output, (int, float)) and output > 0:
provider_limits[mid] = int(output)
if model_ids:
result[provider_id] = model_ids
if provider_limits:
output_limits[provider_id] = provider_limits
models_dict = provider_info.get("models")
if not isinstance(models_dict, dict):
continue
model_ids: List[str] = []
provider_limits: Dict[str, int] = {}
for mid, model_info in models_dict.items():
if not isinstance(mid, str):
continue
model_ids.append(mid)
if isinstance(model_info, dict):
limit = model_info.get("limit")
if isinstance(limit, dict):
output = limit.get("output")
if isinstance(output, (int, float)) and output > 0:
provider_limits[mid] = int(output)
if model_ids:
result[provider_id] = model_ids
if provider_limits:
output_limits[provider_id] = provider_limits
_catalog_cache = result
_model_output_limits = output_limits
logger.debug(
"Loaded model catalog: %d providers, %d total models "
"(%d providers have output limits)",
len(result),
sum(len(m) for m in result.values()),
len(output_limits),
)
return result
_catalog_cache = result
_model_output_limits = output_limits
logger.debug(
"Loaded model catalog: %d providers, %d total models "
"(%d providers have output limits)",
len(result),
sum(len(m) for m in result.values()),
len(output_limits),
)
return result
def _get_model_max_output(provider: str, model: str) -> Optional[int]:
@@ -126,12 +169,12 @@ async def fetch_ollama_models(api_base: str) -> List[str]:
url = f"{api_base.rstrip('/')}/models"
try:
async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session:
async with session.get(url) as resp:
async with session.get(url, headers=_NO_BROTLI_HEADERS) as resp:
if resp.status != 200:
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
return []
data = await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc:
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.debug("Ollama not reachable at %s: %s", api_base, exc)
return []
+5
View File
@@ -33,6 +33,11 @@ class ModelCache:
raw_data: List[Dict[str, Any]]
folders: List[str]
# Every directory under the model roots (including empty ones), as
# recorded by the last scan/hydration. ``None`` means "never recorded"
# (e.g. a persisted snapshot predating this field) and triggers a
# background filesystem backfill in the scanner.
all_folders: Optional[List[str]] = None
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
# Multi-valued companion to version_index: every local file entry of a
+57
View File
@@ -169,6 +169,17 @@ class ModelMetadataProvider(ABC):
"""Published model count for the user; None when unsupported."""
return None
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
"""Fetch raw stored file info via CivitAI's model-versions/mini endpoint.
Only the CivitAI provider implements this (#1100); other providers
already serve raw file names (CivArchive) or cannot resolve this
lookup (SQLite), so the default is None.
"""
return None
class CivitaiModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses Civitai API for metadata"""
@@ -203,6 +214,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
async def get_creator_model_count(self, username: str) -> Optional[int]:
return await self.client.get_creator_model_count(username)
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
return await self.client.get_version_file_mini(version_id, file_id)
class CivArchiveModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses CivArchive API for metadata"""
@@ -700,6 +716,37 @@ class FallbackMetadataProvider(ModelMetadataProvider):
continue
return None
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
rate_limited = False
for provider, label in self._iter_providers():
if rate_limited and label not in _LOCAL_PROVIDER_LABELS:
continue
try:
result = await self._call_with_rate_limit(
label,
provider.get_version_file_mini,
version_id,
file_id,
)
if result:
return result
except RateLimitError as exc:
rate_limited = True
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug(
"Provider %s failed for get_version_file_mini: %s", label, e
)
continue
return None
def _iter_providers(self):
return zip(self.providers, self._provider_labels)
@@ -791,6 +838,16 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
async def get_creator_model_count(self, username: str) -> Optional[int]:
return await self._provider.get_creator_model_count(username)
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
return await self._rate_limit_helper.run(
self._label,
self._provider.get_version_file_mini,
version_id,
file_id,
)
class ModelMetadataProviderManager:
"""Manager for selecting and using model metadata providers"""
+209 -69
View File
@@ -62,10 +62,6 @@ def _is_hidden_relative_path(rel_path: str) -> bool:
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
# TTL (seconds) for the get_all_folders() live-walk cache, so rapid repeated
# requests (modal open + autocomplete) do not re-walk the model roots.
ALL_FOLDERS_CACHE_TTL_SECONDS = 5.0
# Maps a scanner model type to the manager page type used in progress
# broadcasts (e.g. 'lora' -> 'loras').
PAGE_TYPE_MAP = {
@@ -89,6 +85,10 @@ class CacheBuildResult:
hash_index: ModelHashIndex
tags_count: Dict[str, int]
excluded_models: List[str]
# Every directory under the model roots (including empty ones) discovered
# during the scan, or None when the source has no folder information
# (e.g. a persisted snapshot predating folder recording).
all_folders: Optional[List[str]] = None
class ModelScanner:
"""Base service for scanning and managing model files"""
@@ -144,8 +144,9 @@ class ModelScanner:
self._name_display_mode = self._resolve_name_display_mode()
self._cancel_requested = False # Flag for cancellation
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
# Short-lived cache for get_all_folders(): (timestamp, folders) or None
self._all_folders_ttl_cache: Optional[Tuple[float, List[str]]] = None
# Guard against concurrent all-folders backfill walks (cold fallback
# for persisted snapshots that predate folder recording).
self._all_folders_backfill_running = False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
@@ -217,7 +218,6 @@ class ModelScanner:
self._excluded_models = []
self._is_initializing = False
self._name_display_mode = self._resolve_name_display_mode()
self.invalidate_all_folders_cache()
self.bump_cache_version()
try:
@@ -702,7 +702,8 @@ class ModelScanner:
raw_data=valid_entries,
hash_index=hash_index,
tags_count=tags_count,
excluded_models=list(persisted.excluded_models)
excluded_models=list(persisted.excluded_models),
all_folders=list(persisted.all_folders) if persisted.all_folders is not None else None,
)
return scan_result, invalid_entries
@@ -737,6 +738,7 @@ class ModelScanner:
hash_snapshot,
list(scan_result.excluded_models),
autov3_snapshot,
scan_result.all_folders,
)
except Exception as exc:
logger.warning("%s Scanner: Failed to persist cache: %s", self.model_type.capitalize(), exc)
@@ -784,7 +786,12 @@ class ModelScanner:
raw_data=list(self._cache.raw_data),
hash_index=self._hash_index,
tags_count=dict(self._tags_count),
excluded_models=list(self._excluded_models)
excluded_models=list(self._excluded_models),
all_folders=(
list(self._cache.all_folders)
if self._cache.all_folders is not None
else None
),
)
await self._save_persistent_cache(snapshot)
await self._sync_download_history(snapshot.raw_data, source='scan')
@@ -1005,20 +1012,36 @@ class ModelScanner:
await self._broadcast_scan_progress('started', 'reconcile_scan', 0, False)
# Get current cached file paths
cached_size_before = len(self._cache.raw_data)
cached_paths = {item['file_path'] for item in self._cache.raw_data}
path_to_item = {item['file_path']: item for item in self._cache.raw_data}
cached_real_paths = {}
for cached_path in cached_paths:
try:
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
except Exception:
continue
# physical path -> cached business path, for the alias case where the
# same file is reachable under a different path than the cached one
# (overlapping roots / symlink layout changes): keep the existing
# entry instead of delete + re-add (which would re-read metadata and
# re-hash every file). Built lazily on the first miss, because a
# realpath per cached entry is ~half the cost of a no-change
# reconcile and the map is only ever consulted for misses.
cached_real_paths: Optional[Dict[str, str]] = None
def lookup_cached_real_path(real_path: str) -> Optional[str]:
nonlocal cached_real_paths
if cached_real_paths is None:
cached_real_paths = {}
for cached_path in cached_paths:
try:
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
except Exception:
continue
return cached_real_paths.get(real_path)
# Track found files and new files
found_paths = set()
new_files = []
visited_real_paths = set()
discovered_real_files = set()
discovered_folders: Set[str] = set()
# Scan all model roots
for root_path in self.get_model_roots():
@@ -1033,19 +1056,31 @@ class ModelScanner:
continue
visited_real_paths.add(real_root)
# Record every visited directory (including empty ones) so
# the folder tree stays accurate without a live walk.
rel_dir = os.path.relpath(
os.path.abspath(root), os.path.abspath(root_path)
).replace(os.path.sep, "/")
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
discovered_folders.add(rel_dir)
for file in files:
ext = os.path.splitext(file)[1].lower()
if ext in self.file_extensions:
# Construct paths exactly as they would be in cache
file_path = os.path.join(root, file).replace(os.sep, '/')
real_file_path = os.path.realpath(os.path.join(root, file))
# Check if this file is already in cache
if file_path in cached_paths:
found_paths.add(file_path)
continue
cached_real_match = cached_real_paths.get(real_file_path)
# Only a cache miss needs the physical path, so the
# realpath syscalls are paid per changed file rather
# than per file in the library.
real_file_path = os.path.realpath(os.path.join(root, file))
cached_real_match = lookup_cached_real_path(real_file_path)
if cached_real_match:
found_paths.add(cached_real_match)
continue
@@ -1090,6 +1125,9 @@ class ModelScanner:
total_new = len(new_files)
processed_new = 0
last_progress_time = time.time()
# Snapshot the roots once: this matches the walk above (which
# also snapshots them) and avoids a config read per new file.
model_roots = self.get_model_roots()
for i in range(0, total_new, batch_size):
batch = new_files[i:i+batch_size]
for path in batch:
@@ -1098,12 +1136,10 @@ class ModelScanner:
try:
# Find the appropriate root path for this file
root_path = None
model_roots = self.get_model_roots()
normalized_path = os.path.normpath(path)
for potential_root in model_roots:
# Normalize both paths for comparison
normalized_path = os.path.normpath(path)
normalized_root = os.path.normpath(potential_root)
if normalized_path.startswith(normalized_root):
if normalized_path.startswith(os.path.normpath(potential_root)):
root_path = potential_root
break
@@ -1200,25 +1236,41 @@ class ModelScanner:
# Update cache data
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
dedup_removed = 0
seen_paths: set[str] = set()
deduped: list[Dict[str, Any]] = []
for item in reversed(self._cache.raw_data):
path = item.get('file_path', '')
if path not in seen_paths:
seen_paths.add(path)
deduped.append(item)
else:
for tag in item.get('tags', []):
if tag in self._tags_count:
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
if self._tags_count[tag] == 0:
del self._tags_count[tag]
dedup_removed += 1
if dedup_removed > 0:
self._cache.raw_data = list(reversed(deduped))
total_removed += dedup_removed
# Defensive integrity pass: drop entries sharing a business path.
# Duplicates can only be introduced by external code rewriting
# raw_data directly or by this pass's own appends, so an unchanged
# filesystem walk over a clean cache has nothing to clean. The size
# mismatch is an O(1) tell that the snapshot already contained
# duplicates; skipping the O(N) pass when it is provably clean is
# what keeps a no-change Refresh cheap.
if cached_size_before != len(cached_paths) or total_added > 0:
dedup_removed = 0
seen_paths: set[str] = set()
deduped: list[Dict[str, Any]] = []
for item in reversed(self._cache.raw_data):
path = item.get('file_path', '')
if path not in seen_paths:
seen_paths.add(path)
deduped.append(item)
else:
for tag in item.get('tags', []):
if tag in self._tags_count:
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
if self._tags_count[tag] == 0:
del self._tags_count[tag]
dedup_removed += 1
if dedup_removed > 0:
self._cache.raw_data = list(reversed(deduped))
total_removed += dedup_removed
# The walk above visited every directory, so refresh the recorded
# folder list (including empty folders) even when no model files
# changed — e.g. an empty folder was created or removed externally.
sorted_discovered = sorted(discovered_folders, key=lambda x: x.lower())
folders_changed = self._cache.all_folders != sorted_discovered
if folders_changed:
self._cache.all_folders = sorted_discovered
# Resort cache if changes were made
if total_added > 0 or total_removed > 0:
# Update folders list
@@ -1231,6 +1283,8 @@ class ModelScanner:
await self._cache.resort()
await self._persist_current_cache()
elif folders_changed:
await self._persist_current_cache()
logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.")
await self._broadcast_scan_progress(
@@ -1270,22 +1324,73 @@ class ModelScanner:
raise NotImplementedError("Subclasses must implement get_model_roots")
async def get_all_folders(self) -> List[str]:
"""Return every known directory under the model roots.
The directory list (including empty ones) is recorded during cache
scans and hydrated from the persisted snapshot, so this is a pure
in-memory read no filesystem walk ever runs on the event loop
(walking network roots synchronously used to freeze the whole
server, see issue #1110). The result is unioned with the
model-derived folders so it is always a superset of
``cache.folders``.
Cold fallback: when the cache was hydrated from a persisted snapshot
that predates folder recording (``all_folders is None``), a one-shot
background walk is scheduled off the event loop to backfill and
persist the list; until it lands, the models-only folders are
returned.
"""
folders: Set[str] = set()
cache = self._cache
if cache is not None:
folders |= {item.get('folder', '') for item in cache.raw_data}
recorded = getattr(cache, 'all_folders', None)
if recorded is None:
self._schedule_all_folders_backfill()
else:
folders |= set(recorded)
else:
self._schedule_all_folders_backfill()
return sorted(folders, key=lambda x: x.lower())
def _schedule_all_folders_backfill(self) -> None:
"""Kick off a one-shot background folder walk if none is running."""
if self._all_folders_backfill_running:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
self._all_folders_backfill_running = True
loop.create_task(self._run_all_folders_backfill())
async def _run_all_folders_backfill(self) -> None:
"""Walk the roots in a worker thread, then record and persist the result."""
try:
loop = asyncio.get_running_loop()
folders = await loop.run_in_executor(None, self._walk_all_folders_sync)
cache = self._cache
# A scan may have recorded the list while the walk was in flight;
# prefer the fresher scan data in that case.
if cache is not None and cache.all_folders is None:
cache.all_folders = folders
await self._persist_current_cache()
except Exception as exc:
logger.warning(
"%s Scanner: all-folders backfill failed: %s",
self.model_type.capitalize(),
exc,
)
finally:
self._all_folders_backfill_running = False
def _walk_all_folders_sync(self) -> List[str]:
"""Enumerate every directory under the model roots, live from disk.
Unlike the models-only ``cache.folders``, this includes empty
directories, so it stays accurate even when the in-memory cache was
hydrated from a persisted snapshot without a filesystem walk. Hidden
directories (any segment starting with '.') and the pending-delete
staging dir are excluded. The result is unioned with the model-derived
folders so it is always a superset of ``cache.folders``, and cached
for ``ALL_FOLDERS_CACHE_TTL_SECONDS`` to avoid repeated walks.
Runs in a worker thread. Hidden directories (any segment starting
with '.') and the pending-delete staging dir are excluded.
"""
now = time.monotonic()
if self._all_folders_ttl_cache is not None:
cached_at, cached_folders = self._all_folders_ttl_cache
if now - cached_at < ALL_FOLDERS_CACHE_TTL_SECONDS:
return cached_folders
discovered: Set[str] = set()
visited_real_paths: Set[str] = set()
@@ -1307,17 +1412,7 @@ class ModelScanner:
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
discovered.add(rel_dir)
folders = set(discovered)
if self._cache is not None:
folders |= {item.get('folder', '') for item in self._cache.raw_data}
result = sorted(folders, key=lambda x: x.lower())
self._all_folders_ttl_cache = (now, result)
return result
def invalidate_all_folders_cache(self) -> None:
"""Drop the cached get_all_folders() result (e.g. after a move)."""
self._all_folders_ttl_cache = None
return sorted(discovered, key=lambda x: x.lower())
async def _create_default_metadata(self, file_path: str) -> Optional[BaseModelMetadata]:
"""Get model file info and metadata (extensible for different model types)"""
@@ -1339,6 +1434,14 @@ class ModelScanner:
"""Hook for subclasses: adjust entries loaded from the persisted cache."""
return entry
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
"""Hook for subclasses: resolve the location-derived sub_type for a file.
Returns ``None`` when the model type has no location-derived sub-types
(the default), in which case any stored value is left untouched.
"""
return None
@staticmethod
def _normalize_path_value(path: Optional[str]) -> str:
if not path:
@@ -1533,6 +1636,9 @@ class ModelScanner:
else:
self._cache.raw_data = list(scan_result.raw_data)
if scan_result.all_folders is not None:
self._cache.all_folders = list(scan_result.all_folders)
# resort() rebuilds folders and the version index on every path, so a
# separate rebuild_version_index() call here would be redundant.
await self._cache.resort()
@@ -1630,6 +1736,7 @@ class ModelScanner:
processed_files = 0
processed_real_files: Set[str] = set()
visited_real_dirs: Set[str] = set()
discovered_folders: Set[str] = set()
async def handle_progress(current_name: str = '') -> None:
if progress_callback is None:
@@ -1708,6 +1815,13 @@ class ModelScanner:
elif entry.is_dir(follow_symlinks=True):
if _is_excluded_dir(entry.name):
continue
# Record every directory (including empty ones) so
# the folder tree can be served without a live walk.
rel_dir = os.path.relpath(
os.path.abspath(entry.path), os.path.abspath(root_path)
).replace(os.path.sep, "/")
if not _is_hidden_relative_path(rel_dir):
discovered_folders.add(rel_dir)
await scan_recursive(entry.path, root_path, visited_paths)
except Exception as entry_error:
logger.error(f"Error processing entry {entry.path}: {entry_error}")
@@ -1727,7 +1841,8 @@ class ModelScanner:
raw_data=raw_data,
hash_index=hash_index,
tags_count=tags_count,
excluded_models=excluded_models
excluded_models=excluded_models,
all_folders=sorted(discovered_folders, key=lambda x: x.lower()),
)
async def add_model_to_cache(self, metadata_dict: Dict[str, Any], folder: str = '') -> bool:
@@ -1869,6 +1984,20 @@ class ModelScanner:
except Exception as e:
logger.error(f"Error moving metadata file: {e}")
if metadata is not None:
# sub_type is derived from the model's location (e.g. a file
# moved from a checkpoints root into a unet root becomes a
# diffusion_model). Persist the recalculated value into the
# moved metadata file so later metadata-driven cache syncs
# do not revert the cache entry to the stale sub_type.
new_sub_type = self.resolve_sub_type_for_path(target_file)
if new_sub_type and metadata.get('sub_type') != new_sub_type:
metadata['sub_type'] = new_sub_type
try:
await MetadataManager.save_metadata(moved_metadata_path, metadata)
except Exception as e:
logger.error(f"Error persisting sub_type for moved model: {e}")
update_result = await self.update_single_model_cache(source_path, target_file, metadata, recalculate_type=True)
return {
@@ -1970,6 +2099,16 @@ class ModelScanner:
all_folders = set(item['folder'] for item in cache.raw_data)
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
# The move target may live in directories the last scan never saw;
# record the destination folder (and its parents) in the known
# folder list so the folder tree reflects it without a rescan.
if cache.all_folders is not None and folder_value:
parts = folder_value.split("/")
known = set(cache.all_folders)
for i in range(1, len(parts) + 1):
known.add("/".join(parts[:i]))
cache.all_folders = sorted(known, key=lambda x: x.lower())
for tag in cache_entry.get('tags', []):
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
@@ -1977,10 +2116,6 @@ class ModelScanner:
await cache.resort()
# A move may have created new directories; drop the cached live-walk
# result so the next include_empty request sees them.
self.invalidate_all_folders_cache()
if cache_modified:
await self._persist_current_cache()
self.bump_cache_version()
@@ -2064,6 +2199,11 @@ class ModelScanner:
file_path_override=file_path,
)
# Location-derived fields (e.g. the checkpoint sub_type) must be
# re-resolved from the file path rather than trusting the on-disk
# metadata snapshot, which may predate a cross-root move.
desired_entry = self.adjust_cached_entry(desired_entry)
# Ensure sha256 is populated (defensive — metadata should have it)
if (
not desired_entry.get("sha256")
+50 -1
View File
@@ -19,6 +19,9 @@ class PersistedCacheData:
hash_rows: List[Tuple[str, str]]
excluded_models: List[str]
autov3_hash_rows: List[Tuple[str, str]] = field(default_factory=list)
# Every directory under the model roots (including empty ones), or None
# when the snapshot predates folder recording.
all_folders: Optional[List[str]] = None
DEFAULT_LICENSE_FLAGS = 127 # 127 (0b1111111) encodes default CivitAI permissions with all commercial modes enabled.
@@ -128,6 +131,14 @@ class PersistentModelCache:
"SELECT file_path FROM excluded_models WHERE model_type = ?",
(model_type,),
).fetchall()
folder_rows = conn.execute(
"SELECT path FROM folders WHERE model_type = ?",
(model_type,),
).fetchall()
folders_recorded = conn.execute(
"SELECT value FROM cache_meta WHERE key = ?",
(f"folders_recorded:{model_type}",),
).fetchone()
finally:
conn.close()
except Exception as exc:
@@ -216,14 +227,20 @@ class PersistentModelCache:
]
excluded_paths = [row["file_path"] for row in excluded]
all_folders: Optional[List[str]] = None
if folders_recorded is not None:
all_folders = sorted(
(row["path"] for row in folder_rows), key=lambda x: x.lower()
)
return PersistedCacheData(
raw_data=raw_data,
hash_rows=hash_pairs,
excluded_models=excluded_paths,
autov3_hash_rows=autov3_pairs,
all_folders=all_folders,
)
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None) -> None:
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None, all_folders: Optional[Sequence[str]] = None) -> None:
if not self.is_enabled():
return
if not self._schema_initialized:
@@ -469,6 +486,27 @@ class PersistentModelCache:
excluded_inserts,
)
if all_folders is not None:
conn.execute(
"DELETE FROM folders WHERE model_type = ?",
(model_type,),
)
folder_inserts = [
(model_type, path) for path in all_folders if path
]
if folder_inserts:
conn.executemany(
"INSERT OR IGNORE INTO folders (model_type, path) VALUES (?, ?)",
folder_inserts,
)
# Mark the snapshot as having folder data even when the
# library has no subfolders, so an empty list is not
# mistaken for "never recorded" on load.
conn.execute(
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
(f"folders_recorded:{model_type}", "1"),
)
conn.commit()
finally:
conn.close()
@@ -554,6 +592,17 @@ class PersistentModelCache:
file_path TEXT NOT NULL,
PRIMARY KEY (model_type, file_path)
);
CREATE TABLE IF NOT EXISTS folders (
model_type TEXT NOT NULL,
path TEXT NOT NULL,
PRIMARY KEY (model_type, path)
);
CREATE TABLE IF NOT EXISTS cache_meta (
key TEXT PRIMARY KEY,
value TEXT
);
"""
)
self._ensure_additional_model_columns(conn)
-4
View File
@@ -52,7 +52,6 @@ class PersistentRecipeCache:
"file_mtime",
"file_size",
"favorite",
"repair_version",
"preview_nsfw_level",
"loras_json",
"checkpoint_json",
@@ -442,7 +441,6 @@ class PersistentRecipeCache:
file_mtime REAL,
file_size INTEGER,
favorite INTEGER DEFAULT 0,
repair_version INTEGER DEFAULT 0,
preview_nsfw_level INTEGER DEFAULT 0,
loras_json TEXT,
checkpoint_json TEXT,
@@ -541,7 +539,6 @@ class PersistentRecipeCache:
file_mtime,
file_size,
1 if recipe.get("favorite") else 0,
int(recipe.get("repair_version") or 0),
int(recipe.get("preview_nsfw_level") or 0),
loras_json,
checkpoint_json,
@@ -599,7 +596,6 @@ class PersistentRecipeCache:
"created_date": row["created_date"] or 0.0,
"modified": row["modified"] or 0.0,
"favorite": bool(row["favorite"]),
"repair_version": row["repair_version"] or 0,
"preview_nsfw_level": row["preview_nsfw_level"] or 0,
"has_workflow": bool(row["has_workflow"]),
"loras": loras,
+158 -233
View File
@@ -94,8 +94,6 @@ class RecipeScanner:
cls._instance._civitai_client = None # Will be lazily initialized
return cls._instance
REPAIR_VERSION = 4
def __init__(
self,
lora_scanner: Optional[LoraScanner] = None,
@@ -485,32 +483,43 @@ class RecipeScanner:
suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower()))
return suggestions[:limit]
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
def _is_rematch_candidate(
self, entry: dict[str, Any], relaxed: bool = False
) -> bool:
"""Return True when a recipe entry is eligible for local re-matching.
An entry counts as unresolved when its identity is known to be
broken (``isDeleted`` or ``hashInvalid``) or when it is missing
identity fields (``hash``/``file_name``). A healthy entry whose
hash is simply not present in the local library is NOT a candidate:
it may be a recipe imported without downloading the model yet, and
its CivitAI-valid hash must never be overwritten by the imprecise
filename fallback.
hash is simply not present in the local library is NOT a candidate
in the default strict mode: it may be a recipe imported without
downloading the model yet, and its CivitAI-valid hash must never be
overwritten by the imprecise filename fallback.
With ``relaxed=True`` any entry carrying an identifier is a
candidate, including healthy ones the caller opted into trying to
reconnect "Not in Library" entries by file name. Entries without
any identifier are never candidates in either mode.
"""
if not isinstance(entry, dict):
return False
unresolved = (
entry.get("isDeleted")
or entry.get("hashInvalid")
or not entry.get("hash")
or not entry.get("file_name")
)
has_identifier = (
entry.get("hash")
or entry.get("modelVersionId")
or entry.get("id")
or entry.get("file_name")
)
return bool(unresolved and has_identifier)
if not has_identifier:
return False
if relaxed:
return True
unresolved = (
entry.get("isDeleted")
or entry.get("hashInvalid")
or not entry.get("hash")
or not entry.get("file_name")
)
return bool(unresolved)
async def _build_rematch_autov3_cache(self) -> dict[str, dict[str, Any]]:
"""Build a version-cached map of computed AutoV3 hashes to local items.
@@ -811,208 +820,9 @@ class RecipeScanner:
"""Check if cancellation has been requested."""
return self._cancel_requested
async def repair_all_recipes(
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
async def rematch_recipe_by_id(
self, recipe_id: str, *, relaxed: bool = False
) -> Dict[str, Any]:
"""Repair all recipes by enrichment with Civitai and embedded metadata.
Args:
persistence_service: Service for saving updated recipes
progress_callback: Optional callback for progress updates
Returns:
Dict summary of repair results
"""
if progress_callback:
await progress_callback({"status": "started"})
async with self._mutation_lock:
cache = await self.get_cached_data()
all_recipes = list(cache.raw_data)
total = len(all_recipes)
repaired_count = 0
skipped_count = 0
errors_count = 0
civitai_client = await self._get_civitai_client()
self.reset_cancellation()
for i, recipe in enumerate(all_recipes):
if self.is_cancelled():
logger.info("Recipe repair cancelled by user")
if progress_callback:
await progress_callback(
{
"status": "cancelled",
"current": i,
"total": total,
"repaired": repaired_count,
"skipped": skipped_count,
"errors": errors_count,
}
)
return {
"success": False,
"status": "cancelled",
"repaired": repaired_count,
"skipped": skipped_count,
"errors": errors_count,
"total": total,
}
try:
# Report progress
if progress_callback:
await progress_callback(
{
"status": "processing",
"current": i + 1,
"total": total,
"recipe_name": recipe.get("name", "Unknown"),
}
)
if await self._repair_single_recipe(recipe, civitai_client):
repaired_count += 1
else:
skipped_count += 1
except Exception as e:
logger.error(
f"Error repairing recipe {recipe.get('file_path')}: {e}"
)
errors_count += 1
# Final progress update
if progress_callback:
await progress_callback(
{
"status": "completed",
"repaired": repaired_count,
"skipped": skipped_count,
"errors": errors_count,
"total": total,
}
)
return {
"success": True,
"repaired": repaired_count,
"skipped": skipped_count,
"errors": errors_count,
"total": total,
}
async def repair_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
"""Repair a single recipe by its ID.
Args:
recipe_id: ID of the recipe to repair
Returns:
Dict summary of repair result
"""
async with self._mutation_lock:
# Get raw recipe from cache directly to avoid formatted fields
cache = await self.get_cached_data()
recipe = next(
(r for r in cache.raw_data if str(r.get("id", "")) == recipe_id), None
)
if not recipe:
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
civitai_client = await self._get_civitai_client()
success = await self._repair_single_recipe(recipe, civitai_client)
# If successfully repaired, we should return the formatted version for the UI
return {
"success": True,
"repaired": 1 if success else 0,
"skipped": 0 if success else 1,
"recipe": await self.get_recipe_by_id(recipe_id) if success else recipe,
}
async def _repair_single_recipe(
self, recipe: Dict[str, Any], civitai_client: Any
) -> bool:
"""Internal helper to repair a single recipe object.
Args:
recipe: The recipe dictionary to repair (modified in-place)
civitai_client: Authenticated Civitai client
Returns:
bool: True if recipe was repaired or updated, False if skipped
"""
# 1. Skip if already at latest repair version
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
return False
# 1.5 Detect and clear corrupted checkpoint (LoRA data saved as checkpoint).
# A checkpoint whose modelVersionId also appears in a LoRA entry is
# definitely wrong — the CivitAI import code used to pick
# modelVersionIds[0] as the checkpoint, which was often a LoRA.
# Clearing it lets the enrichment flow re-resolve the correct
# checkpoint from CivitAI image metadata.
cp = recipe.get("checkpoint")
lora_mvids = {
l.get("modelVersionId")
for l in recipe.get("loras", [])
if l.get("modelVersionId")
}
if cp and cp.get("modelVersionId") and cp["modelVersionId"] in lora_mvids:
cp_mvid = cp["modelVersionId"]
logger.info(
"Recipe %s: checkpoint modelVersionId %s matches a LoRA — "
"clearing corrupted checkpoint and removing matching LoRA entry",
recipe.get("id"),
cp_mvid,
)
recipe["checkpoint"] = None
recipe["loras"] = [
l for l in recipe.get("loras", [])
if l.get("modelVersionId") != cp_mvid
]
# 2. Identification: Is repair needed?
has_checkpoint = (
"checkpoint" in recipe
and recipe["checkpoint"]
and recipe["checkpoint"].get("name")
)
gen_params = recipe.get("gen_params", {})
has_prompt = bool(gen_params.get("prompt"))
needs_repair = not has_checkpoint or not has_prompt
if not needs_repair:
# Even if no repair needed, we mark it with version if it was processed
# Always update and save because if we are here, the version is old (checked in step 1)
recipe["repair_version"] = self.REPAIR_VERSION
await self._save_recipe_persistently(recipe)
return True
# 3. Use Enricher to repair/enrich
try:
from ..recipes.enrichment import RecipeEnricher
updated = await RecipeEnricher.enrich_recipe(recipe, civitai_client)
except Exception as e:
logger.error(f"Error enriching recipe {recipe.get('id')}: {e}")
updated = False
# 4. Mark version and save if updated or just marking version
# If we updated it, OR if the version is old (which we know it is if we are here), save it.
# Actually, if we are here and updated is False, it means we tried to repair but couldn't/didn't need to.
# But we still want to mark it as processed so we don't try again until version bump.
if updated or recipe.get("repair_version", 0) < self.REPAIR_VERSION:
recipe["repair_version"] = self.REPAIR_VERSION
await self._save_recipe_persistently(recipe)
return True
return False
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
Logs one INFO summary line for this run and delegates the per-recipe
@@ -1020,12 +830,14 @@ class RecipeScanner:
Args:
recipe_id: ID of the recipe to rematch
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns:
Dict summary of the rematch result (see ``_rematch_recipe_by_id``).
Raises RecipeNotFoundError when the recipe is missing.
"""
result = await self._rematch_recipe_by_id(recipe_id)
result = await self._rematch_recipe_by_id(recipe_id, relaxed=relaxed)
recipe_name = (result.get("recipe") or {}).get("name") or recipe_id
logger.info(
"Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors",
@@ -1038,7 +850,9 @@ class RecipeScanner:
)
return result
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
async def _rematch_recipe_by_id(
self, recipe_id: str, *, relaxed: bool = False
) -> Dict[str, Any]:
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
Match snapshots (local hash cache, computed autov3 cache, filename
@@ -1049,12 +863,16 @@ class RecipeScanner:
Args:
recipe_id: ID of the recipe to rematch
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns:
Dict summary of the rematch result with unified counters
(matched_recipes, matched_entries, unresolved_recipes,
unresolved_entries plus the legacy rematched/skipped/errors
fields) and a per-entry ``details`` report. The legacy ``skipped``
fields) and a per-entry ``details`` report plus a flattened
``l4_matches`` list (filename-level matches for review/undo,
consistent with the bulk/global paths). The legacy ``skipped``
field means "recipe not updated" and overlaps
``unresolved_recipes`` (a recipe with unmatched candidates counts
as both). Raises RecipeNotFoundError when the recipe is missing.
@@ -1075,7 +893,8 @@ class RecipeScanner:
try:
rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache, filename_cache
recipe, local_cache, autov3_cache, filename_cache,
relaxed=relaxed,
)
except RecipePersistenceError as exc:
logger.error(
@@ -1094,12 +913,16 @@ class RecipeScanner:
"unresolved_recipes": 0,
"unresolved_entries": 0,
"details": {"matched": [], "unresolved": []},
"l4_matches": [],
"recipe": recipe,
"error": str(exc),
}
unresolved_entries = len(details["unresolved"])
unresolved_recipes = 1 if unresolved_entries > 0 else 0
# Flattened L4 matches for the results modal, consistent with
# the bulk/global paths.
l4_matches = self._collect_l4_matches(recipe_id, details)
if rematched == 0:
return {
@@ -1111,6 +934,7 @@ class RecipeScanner:
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"details": details,
"l4_matches": l4_matches,
"recipe": recipe,
}
@@ -1124,6 +948,7 @@ class RecipeScanner:
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"details": details,
"l4_matches": l4_matches,
"recipe": await self.get_recipe_by_id(recipe_id),
}
@@ -1133,6 +958,8 @@ class RecipeScanner:
local_cache: dict[str, dict[str, Any]],
autov3_cache: dict[str, dict[str, Any]],
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
*,
relaxed: bool = False,
) -> Tuple[int, int, Dict[str, Any]]:
"""Rematch a single recipe's lora/checkpoint entries against local models.
@@ -1148,16 +975,24 @@ class RecipeScanner:
autov3_cache: L3 computed-autov3 cache snapshot
filename_cache: L4 filename cache snapshot, or None to disable
the filename fallback
relaxed: When True, healthy entries ("Not in Library") are also
rematch candidates. Anti-churn rule: an entry that is a
candidate ONLY because of relaxed mode is skipped when its
hash already resolves in the L1 ``local_cache`` it is
already correctly linked and rematching would only add noise
and a pointless snapshot.
Returns:
Tuple of (rematched_entries, errors, details). The errors element
is always 0 on a normal return a persistence failure RAISES
``RecipePersistenceError`` so callers can count it. ``details``
carries the per-entry outcome:
``{"matched": [{type, entry, file_name, match_level}],
``{"matched": [{type, entry, file_name, match_level, lora_index?}],
"unresolved": [{type, entry}]}`` where an unresolved entry is a
rematch candidate that found no local match an expected outcome
(the model may simply not exist locally), not an error.
``lora_index`` is only present for lora entries (the checkpoint
restore endpoint needs no index).
Raises:
RecipePersistenceError: when the recipe changed but
@@ -1166,11 +1001,23 @@ class RecipeScanner:
rematched = 0
details: Dict[str, Any] = {"matched": [], "unresolved": []}
def is_actionable_candidate(entry: Dict[str, Any]) -> bool:
"""Apply candidacy plus the relaxed-mode anti-churn rule."""
if self._is_rematch_candidate(entry):
return True
if not relaxed or not self._is_rematch_candidate(entry, relaxed=True):
return False
# Relaxed-only candidate: skip when the stored hash already
# resolves in the L1 local cache — the entry is already correctly
# linked and rematching would just add noise and a snapshot.
entry_hash = (entry.get("hash") or "").lower()
return local_cache.get(entry_hash) is None
# Lora entries
loras = recipe.get("loras", [])
if isinstance(loras, list):
for entry in loras:
if not self._is_rematch_candidate(entry):
for lora_index, entry in enumerate(loras):
if not is_actionable_candidate(entry):
continue
item, level = await self._match_rematch_entry_with_level(
entry,
@@ -1194,6 +1041,7 @@ class RecipeScanner:
"entry": self._entry_identifier(entry),
"file_name": item.get("file_name") or "",
"match_level": level,
"lora_index": lora_index,
}
)
self._write_rematch_lora_entry(entry, item)
@@ -1203,7 +1051,7 @@ class RecipeScanner:
# silently since ``entry.get`` on a str would raise AttributeError).
checkpoint = recipe.get("checkpoint")
if isinstance(checkpoint, dict):
if self._is_rematch_candidate(checkpoint):
if is_actionable_candidate(checkpoint):
item, level = await self._match_rematch_entry_with_level(
checkpoint,
local_cache,
@@ -1268,8 +1116,36 @@ class RecipeScanner:
self._update_fts_index_for_recipe(recipe, "update")
return (rematched, 0, details)
@staticmethod
def _collect_l4_matches(
recipe_id: Any, details: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Flatten a recipe's L4 (filename-level) matches for review.
Returns ``[{recipe_id, type, entry, file_name, lora_index?}]`` rows
one per matched detail at level L4. ``lora_index`` is only present
for lora entries (checkpoint restore needs no index).
"""
rows: List[Dict[str, Any]] = []
for match in details.get("matched", []):
if match.get("match_level") != "L4":
continue
row: Dict[str, Any] = {
"recipe_id": recipe_id,
"type": match.get("type"),
"entry": match.get("entry"),
"file_name": match.get("file_name"),
}
if "lora_index" in match:
row["lora_index"] = match["lora_index"]
rows.append(row)
return rows
async def rematch_all_recipes(
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
self,
progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None,
*,
relaxed: bool = False,
) -> Dict[str, Any]:
"""Rematch every recipe's deleted lora/checkpoint entries locally.
@@ -1283,14 +1159,19 @@ class RecipeScanner:
Args:
progress_callback: Optional callback for progress updates
(started/processing/cancelled/completed events).
(started/processing/cancelled/completed events). The
completed/cancelled payloads carry ``l4_matches``, a
flattened list of filename-level matches for review/undo.
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns:
Dict summary of the rematch run with unified counters
(matched_recipes/matched_entries/unresolved_recipes/unresolved_
entries plus the legacy success/status/rematched/skipped/errors/
total fields). ``rematched`` (legacy) counts updated recipes
use ``matched_entries`` for the entry-level total.
total fields) and ``l4_matches``. ``rematched`` (legacy) counts
updated recipes use ``matched_entries`` for the entry-level
total.
"""
start_time = time.perf_counter()
@@ -1312,6 +1193,7 @@ class RecipeScanner:
unresolved_entries = 0
skipped_count = 0
errors_count = 0
l4_matches: List[Dict[str, Any]] = []
for i, recipe in enumerate(all_recipes):
if self.is_cancelled():
@@ -1340,6 +1222,7 @@ class RecipeScanner:
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
}
)
return {
@@ -1353,6 +1236,7 @@ class RecipeScanner:
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
}
try:
@@ -1368,11 +1252,15 @@ class RecipeScanner:
)
rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache, filename_cache
recipe, local_cache, autov3_cache, filename_cache,
relaxed=relaxed,
)
if rematched > 0:
matched_recipes += 1
matched_entries += rematched
l4_matches.extend(
self._collect_l4_matches(recipe.get("id"), details)
)
else:
skipped_count += 1
@@ -1418,6 +1306,7 @@ class RecipeScanner:
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
}
)
@@ -1431,9 +1320,12 @@ class RecipeScanner:
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
}
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]:
async def rematch_recipes_bulk(
self, recipe_ids: List[str], *, relaxed: bool = False
) -> Dict[str, Any]:
"""Rematch a set of recipes by their IDs.
Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are
@@ -1444,14 +1336,18 @@ class RecipeScanner:
Args:
recipe_ids: List of recipe ids to rematch.
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns:
Dict summary of the bulk run with unified counters
(matched_recipes, matched_entries, unresolved_recipes,
unresolved_entries plus the legacy total/rematched/skipped/errors
fields) and a per-recipe ``details`` list. The legacy ``rematched``
field is the total entry count (same as ``matched_entries``)
unlike ``rematch_all_recipes`` where it counts updated recipes.
fields), a per-recipe ``details`` list, and ``l4_matches`` a
flattened list of filename-level matches for review/undo. The
legacy ``rematched`` field is the total entry count (same as
``matched_entries``) unlike ``rematch_all_recipes`` where it
counts updated recipes.
"""
total = len(recipe_ids)
matched_recipes = 0
@@ -1462,10 +1358,13 @@ class RecipeScanner:
errors = 0
recipes: List[Dict[str, Any]] = []
details_list: List[Dict[str, Any]] = []
l4_matches: List[Dict[str, Any]] = []
for recipe_id in recipe_ids:
try:
result = await self._rematch_recipe_by_id(recipe_id)
result = await self._rematch_recipe_by_id(
recipe_id, relaxed=relaxed
)
if result.get("success"):
matched_recipes += result.get("matched_recipes", 0)
matched_entries += result.get("matched_entries", 0)
@@ -1478,6 +1377,9 @@ class RecipeScanner:
details_list.append(
{"recipe_id": recipe_id, **result["details"]}
)
l4_matches.extend(
self._collect_l4_matches(recipe_id, result["details"])
)
else:
errors += result.get("errors", 0)
except RecipeNotFoundError:
@@ -1512,12 +1414,22 @@ class RecipeScanner:
"unresolved_entries": unresolved_entries,
"recipes": recipes,
"details": details_list,
"l4_matches": l4_matches,
}
def _write_rematch_lora_entry(
self, entry: Dict[str, Any], item: Dict[str, Any]
) -> None:
"""Write back a matched local model to a lora recipe entry."""
# Snapshot the pre-rematch state so the association can be restored
# later (undo), mirroring the manual reconnect flow in
# ``update_lora_entry``. Never nest snapshots.
snapshot = {
key: copy.deepcopy(value)
for key, value in entry.items()
if key != "reconnectSnapshot"
}
entry["isDeleted"] = False
entry["hashInvalid"] = False
@@ -1541,6 +1453,8 @@ class RecipeScanner:
if civitai.get("name"):
entry["modelVersionName"] = civitai["name"]
entry["reconnectSnapshot"] = snapshot
def _write_rematch_checkpoint_entry(
self, entry: Dict[str, Any], item: Dict[str, Any]
) -> None:
@@ -1552,6 +1466,15 @@ class RecipeScanner:
when they already exist on the entry (or written fresh for the
identifier key when neither identifier form exists).
"""
# Snapshot the pre-rematch state so the association can be restored
# later (undo), mirroring the manual reconnect flow. Never nest
# snapshots.
snapshot = {
key: copy.deepcopy(value)
for key, value in entry.items()
if key != "reconnectSnapshot"
}
entry["isDeleted"] = False
entry["hashInvalid"] = False
@@ -1592,6 +1515,8 @@ class RecipeScanner:
else:
entry["modelVersionId"] = civ_id
entry["reconnectSnapshot"] = snapshot
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
"""Helper to save a recipe to both JSON and EXIF metadata."""
recipe_id = recipe.get("id")
+1
View File
@@ -116,6 +116,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"backup_retention_count": 5,
"use_new_license_icons": True,
"group_by_model": False,
"sticky_controls": False,
# AI / LLM provider configuration (BYOK)
"llm_provider": "openai", # "openai" | "ollama" | "custom"
"llm_api_key": "",
-26
View File
@@ -20,8 +20,6 @@ class WebSocketManager:
self._last_init_progress: Dict[str, Dict[str, Any]] = {}
# Add auto-organize progress tracking
self._auto_organize_progress: Optional[Dict[str, Any]] = None
# Add recipe repair progress tracking
self._recipe_repair_progress: Optional[Dict[str, Any]] = None
# Add recipe rematch progress tracking
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
self._auto_organize_lock = asyncio.Lock()
@@ -193,14 +191,6 @@ class WebSocketManager:
# Broadcast via WebSocket
await self.broadcast(data)
async def broadcast_recipe_repair_progress(self, data: Dict[str, Any]):
"""Broadcast recipe repair progress to connected clients"""
# Store progress data in memory
self._recipe_repair_progress = data
# Broadcast via WebSocket
await self.broadcast(data)
def get_auto_organize_progress(self) -> Optional[Dict[str, Any]]:
"""Get current auto-organize progress"""
return self._auto_organize_progress
@@ -209,22 +199,6 @@ class WebSocketManager:
"""Clear auto-organize progress data"""
self._auto_organize_progress = None
def get_recipe_repair_progress(self) -> Optional[Dict[str, Any]]:
"""Get current recipe repair progress"""
return self._recipe_repair_progress
def cleanup_recipe_repair_progress(self):
"""Clear recipe repair progress data if it is in a finished state"""
if self._recipe_repair_progress and self._recipe_repair_progress.get('status') in ['completed', 'cancelled', 'error']:
self._recipe_repair_progress = None
def is_recipe_repair_running(self) -> bool:
"""Check if recipe repair is currently running"""
if not self._recipe_repair_progress:
return False
status = self._recipe_repair_progress.get('status')
return status in ['started', 'processing']
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
"""Broadcast recipe rematch progress to connected clients"""
# Store progress data in memory
-6
View File
@@ -77,9 +77,6 @@ class BaseModelMetadata:
last_checked_at: float = 0 # Last checked timestamp
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
autov3: Optional[str] = None # CivitAI AutoV3 hash (12-char lowercase hex); "" = checked but unavailable, None = not checked
trainedWords: List[str] = field(
default_factory=list
) # Trigger words / activation prompts (source-agnostic)
_unknown_fields: Dict[str, Any] = field(
default_factory=dict, repr=False, compare=False
) # Store unknown fields
@@ -92,9 +89,6 @@ class BaseModelMetadata:
if self.tags is None:
self.tags = []
if self.trainedWords is None:
self.trainedWords = []
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "BaseModelMetadata":
"""Create instance from dictionary"""
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.2.1"
version = "1.2.2"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
+142
View File
@@ -592,3 +592,145 @@ button:disabled,
margin-top: 2px;
flex-shrink: 0;
}
/* Recipe Rematch Options Modal */
#rematchOptionsModal .modal-body {
padding: var(--space-3);
}
#rematchOptionsModal .confirmation-message {
color: var(--text-color);
margin-bottom: var(--space-3);
font-size: 1em;
line-height: 1.5;
}
/* Selectable option card click anywhere toggles the checkbox (label wrap).
Checkmark follows the batch-import modal's custom checkbox pattern. */
#rematchOptionsModal .rematch-option-card {
position: relative;
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3);
background: var(--surface-subtle);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
user-select: none;
transition: var(--transition-base);
}
#rematchOptionsModal .rematch-option-card:hover {
border-color: var(--lora-accent);
}
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:checked) {
border-color: var(--lora-accent);
background: oklch(from var(--lora-accent) l c h / 0.08);
}
/* Visually hidden but keyboard-focusable (focus ring lands on the card). */
#rematchOptionsModal .rematch-option-card input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:focus-visible) {
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
}
#rematchOptionsModal .rematch-option-checkmark {
width: 18px;
height: 18px;
margin-top: 1px;
flex-shrink: 0;
border: 2px solid var(--border-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-base);
background: var(--bg-color);
}
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark {
background: var(--lora-accent);
border-color: var(--lora-accent);
}
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free', sans-serif;
font-weight: 900;
color: var(--lora-text);
font-size: 12px;
}
#rematchOptionsModal .rematch-option-text {
display: flex;
flex-direction: column;
gap: var(--space-1);
color: var(--text-color);
min-width: 0;
}
#rematchOptionsModal .rematch-option-title {
font-weight: 600;
font-size: 0.95em;
}
#rematchOptionsModal .rematch-option-caveat {
display: flex;
align-items: flex-start;
gap: var(--space-2);
font-size: 0.85em;
line-height: 1.4;
color: var(--text-muted);
}
#rematchOptionsModal .rematch-option-caveat i {
color: var(--lora-accent);
margin-top: 2px;
flex-shrink: 0;
}
/* Recipe Rematch Summary Modal (dynamically built by RematchSummaryModal.js;
stat cards / failure table / summary header come from
metadata-refresh-result.css and download-batch-summary.css). */
.rematch-summary-modal {
max-width: 700px;
}
.rematch-cancelled-note {
display: flex;
align-items: flex-start;
gap: var(--space-2);
margin: 0 0 var(--space-2) 0;
font-size: var(--text-sm);
color: var(--color-warning);
}
.rematch-cancelled-note i {
margin-top: 2px;
flex-shrink: 0;
}
/* Review section heading uses the accent (review, not failure) instead of
the failure-section error color. */
.rematch-review-section h4 {
color: var(--lora-accent);
}
#rematchSummaryModal .rematch-undo-btn {
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
white-space: nowrap;
}
#rematchSummaryModal tr.undone td:not(.rematch-undo-cell) {
text-decoration: line-through;
opacity: 0.6;
}
+1 -1
View File
@@ -174,7 +174,7 @@
z-index: var(--z-toast);
display: flex;
flex-direction: column;
align-items: flex-end;
/* No align-items (defaults to stretch) so every toast shares one equal width */
gap: 10px;
padding: 8px 20px 0; /* Small breathing room below the header */
pointer-events: none; /* Allow clicking through the container */
+33 -8
View File
@@ -25,6 +25,23 @@
box-shadow: var(--shadow-xs);
}
/* Wrapper around the controls bar and breadcrumb nav. With the sticky-controls
setting off it is transparent to layout (display: contents), preserving the
original behavior (only the breadcrumb stays visible). When enabled, the whole
wrapper sticks as one unit so the two bars can never drift apart. */
.sticky-topbar {
display: contents;
}
body.sticky-controls .sticky-topbar {
display: block;
position: sticky;
top: 0;
z-index: calc(var(--z-header) - 1);
background: var(--bg-color);
box-shadow: var(--shadow-xs);
}
/* Responsive container for larger screens */
@media (min-width: 2150px) {
.container {
@@ -201,20 +218,17 @@
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 11px;
font-weight: 600;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
font-weight: 500;
line-height: 1;
text-transform: uppercase;
border-radius: var(--border-radius-xs);
background-color: var(--shortcut-bg);
border: 1px solid var(--shortcut-border);
box-shadow: var(--shortcut-shadow);
color: var(--shortcut-text);
vertical-align: middle;
opacity: 0.8;
transition: var(--transition-base);
}
@@ -225,10 +239,21 @@
border-color: var(--shortcut-border-hover);
}
/* Invert the keycap on active (accent-filled) buttons for contrast.
Must come after the hover rule above so it wins on active+hover. */
.control-group button.active .shortcut-key,
.control-group button.active:hover .shortcut-key {
color: var(--lora-accent);
background: rgba(255, 255, 255, 0.92);
border-color: transparent;
opacity: 1;
}
/* Ensure correct vertical alignment for text+shortcut */
.control-group button span {
display: inline-flex;
align-items: center;
gap: 6px;
}
/* Select dropdown styling */
+7
View File
@@ -44,6 +44,13 @@
pointer-events: auto !important;
}
/* Keep the fixed-position sidebar anchored when highlighted, otherwise
.onboarding-target-highlight's position: relative would pull it into
normal flow and it would move away from the spotlight cutout */
.folder-sidebar.onboarding-target-highlight {
position: fixed;
}
.onboarding-popup {
position: absolute;
background: var(--lora-surface);
+1
View File
@@ -184,6 +184,7 @@ export const DOWNLOAD_ENDPOINTS = {
downloadGet: '/api/lm/download-model-get',
cancelGet: '/api/lm/cancel-download-get',
progress: '/api/lm/download-progress',
routing: '/api/lm/download/routing',
exampleImages: '/api/lm/force-download-example-images', // Re-process example images ignoring previous status
exampleImagesMissing: '/api/lm/download-example-images' // Download only missing example images
};
+7 -35
View File
@@ -20,7 +20,6 @@ const RECIPE_ENDPOINTS = {
move: '/api/lm/recipe/move',
moveBulk: '/api/lm/recipes/move-bulk',
bulkDelete: '/api/lm/recipes/bulk-delete',
repairBulk: '/api/lm/recipes/repair-bulk',
rematchBulk: '/api/lm/recipes/rematch-bulk',
rematchSingle: '/api/lm/recipe/{recipe_id}/rematch',
};
@@ -678,7 +677,7 @@ export class RecipeSidebarApiClient {
};
}
async repairBulkModels(filePaths) {
async rematchBulkModels(filePaths, options = {}) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
@@ -691,36 +690,11 @@ export class RecipeSidebarApiClient {
throw new Error('No recipe IDs could be derived from file paths');
}
const response = await fetch(this.apiConfig.endpoints.repairBulk, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_ids: recipeIds,
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to repair recipes');
}
return result;
}
async rematchBulkModels(filePaths) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
const recipeIds = filePaths
.map((path) => extractRecipeId(path))
.filter((id) => !!id);
if (recipeIds.length === 0) {
throw new Error('No recipe IDs could be derived from file paths');
const body = { recipe_ids: recipeIds };
// Only sent when opted in — the strict body stays exactly
// {recipe_ids} for backward compatibility.
if (options.relaxed === true) {
body.relaxed = true;
}
const response = await fetch(this.apiConfig.endpoints.rematchBulk, {
@@ -728,9 +702,7 @@ export class RecipeSidebarApiClient {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_ids: recipeIds,
}),
body: JSON.stringify(body),
});
const result = await response.json();
@@ -41,13 +41,9 @@ export class BulkContextMenu extends BaseContextMenu {
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
const repairMetadataItem = this.menu.querySelector('[data-action="repair-metadata"]');
const reimportMetadataItem = this.menu.querySelector('[data-action="reimport-metadata"]');
const rematchMetadataItem = this.menu.querySelector('[data-action="rematch-metadata"]');
if (repairMetadataItem) {
repairMetadataItem.style.display = config.repairMetadata ? 'flex' : 'none';
}
if (reimportMetadataItem) {
reimportMetadataItem.style.display = config.reimportMetadata ? 'flex' : 'none';
}
@@ -283,9 +279,6 @@ export class BulkContextMenu extends BaseContextMenu {
case 'delete-all':
bulkManager.showBulkDeleteModal();
break;
case 'repair-metadata':
bulkManager.repairSelectedRecipes();
break;
case 'rematch-metadata':
bulkManager.rematchSelectedRecipes();
break;
@@ -25,6 +25,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
showMenu(x, y, card) {
super.showMenu(x, y, card);
this.updateExcludeMenuItem();
this.updateEnrichMenuItem(card);
// Update the "Move to other root" label based on current model type
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
@@ -4,6 +4,8 @@ import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js';
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js';
import { showRematchSummary } from '../RematchSummaryModal.js';
export class GlobalContextMenu extends BaseContextMenu {
constructor() {
@@ -23,7 +25,6 @@ export class GlobalContextMenu extends BaseContextMenu {
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
const rematchRecipesItem = this.menu.querySelector('[data-action="rematch-recipes"]');
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
@@ -41,7 +42,6 @@ export class GlobalContextMenu extends BaseContextMenu {
cleanupExamplesItem?.classList.add('hidden');
excludedModelsItem?.classList.add('hidden');
groupByModelItem?.classList.add('hidden');
repairRecipesItem?.classList.remove('hidden');
rematchRecipesItem?.classList.remove('hidden');
} else {
modelUpdateItem?.classList.remove('hidden');
@@ -50,7 +50,6 @@ export class GlobalContextMenu extends BaseContextMenu {
cleanupExamplesItem?.classList.remove('hidden');
excludedModelsItem?.classList.remove('hidden');
groupByModelItem?.classList.remove('hidden');
repairRecipesItem?.classList.add('hidden');
rematchRecipesItem?.classList.add('hidden');
}
@@ -95,11 +94,6 @@ export class GlobalContextMenu extends BaseContextMenu {
console.error('Failed to refresh missing license metadata:', error);
});
break;
case 'repair-recipes':
this.repairRecipes(menuItem).catch((error) => {
console.error('Failed to repair recipes:', error);
});
break;
case 'rematch-recipes':
this.rematchRecipes(menuItem).catch((error) => {
console.error('Failed to rematch recipes:', error);
@@ -371,100 +365,19 @@ export class GlobalContextMenu extends BaseContextMenu {
return `${displayName}s`;
}
async repairRecipes(menuItem) {
if (this._repairInProgress) {
async rematchRecipes(menuItem) {
if (this._rematchInProgress) {
return;
}
this._repairInProgress = true;
menuItem?.classList.add('disabled');
const loadingMessage = translate(
'globalContextMenu.repairRecipes.loading',
{},
'Repairing recipe data...'
);
const progressUI = state.loadingManager?.showEnhancedProgress(loadingMessage);
progressUI?.showCancelButton(() => this.cancelRepair());
try {
const response = await fetch('/api/lm/recipes/repair', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to start repair');
}
// Poll for progress (or wait for WebSocket if preferred, but polling is simpler for this implementation)
let isComplete = false;
while (!isComplete && this._repairInProgress) {
const progressResponse = await fetch('/api/lm/recipes/repair-progress');
if (progressResponse.ok) {
const progressResult = await progressResponse.json();
if (progressResult.success && progressResult.progress) {
const p = progressResult.progress;
if (p.status === 'processing') {
const percent = (p.current / p.total) * 100;
progressUI?.updateProgress(percent, p.recipe_name, `${loadingMessage} (${p.current}/${p.total})`);
} else if (p.status === 'completed') {
isComplete = true;
progressUI?.complete(translate(
'globalContextMenu.repairRecipes.success',
{ count: p.repaired },
`Repaired ${p.repaired} recipes.`
));
showToast('globalContextMenu.repairRecipes.success', { count: p.repaired }, 'success');
// Refresh recipes page if active
if (window.recipesPage) {
window.recipesPage.refresh();
}
} else if (p.status === 'error') {
throw new Error(p.error || 'Repair failed');
} else if (p.status === 'cancelled') {
isComplete = true;
progressUI?.complete(translate(
'globalContextMenu.repairRecipes.cancelled',
{ count: p.repaired },
`Repair cancelled. ${p.repaired} recipes were repaired.`
));
showToast('globalContextMenu.repairRecipes.cancelled', { count: p.repaired }, 'info');
}
} else if (progressResponse.status === 404) {
// Progress might have finished quickly and been cleaned up
isComplete = true;
progressUI?.complete();
}
}
if (!isComplete) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
} catch (error) {
console.error('Recipe repair failed:', error);
progressUI?.complete(translate('globalContextMenu.repairRecipes.error', { message: error.message }, 'Repair failed: {message}'));
showToast('globalContextMenu.repairRecipes.error', { message: error.message }, 'error');
} finally {
this._repairInProgress = false;
menuItem?.classList.remove('disabled');
}
// Collect options (relaxed matching) before starting anything; the
// run only begins when the user confirms the dialog.
rematchModalManager.showOptionsModal({
onConfirm: ({ relaxed }) => this._startRematch(menuItem, relaxed),
});
}
async cancelRepair() {
try {
await fetch('/api/lm/recipes/cancel-repair', {
method: 'POST',
});
} catch (error) {
console.error('Failed to cancel recipe repair:', error);
}
}
async rematchRecipes(menuItem) {
async _startRematch(menuItem, relaxed = false) {
if (this._rematchInProgress) {
return;
}
@@ -485,6 +398,7 @@ export class GlobalContextMenu extends BaseContextMenu {
const response = await fetch('/api/lm/recipes/rematch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: !!relaxed }),
});
const result = await response.json();
@@ -512,48 +426,32 @@ export class GlobalContextMenu extends BaseContextMenu {
const recipes = p.matched_recipes ?? p.rematched ?? 0;
const failures = p.errors || 0;
const unresolved = p.unresolved_entries ?? 0;
if (entries > 0) {
const successKey = failures > 0
? 'globalContextMenu.rematchRecipes.successErrors'
: 'globalContextMenu.rematchRecipes.success';
const successText = failures > 0
? `Matched ${entries} entries across ${recipes} recipes, ${failures} failed.`
: `Matched ${entries} entries across ${recipes} recipes.`;
progressUI?.complete(translate(
successKey,
{ count: recipes, recipes, entries, failures },
successText
));
showToast(successKey, { count: recipes, recipes, entries, failures }, failures > 0 ? 'warning' : 'success');
} else if (failures > 0) {
// Nothing matched and at least one recipe
// errored — "no rematch needed" would be
// actively misleading here.
progressUI?.complete(translate(
'globalContextMenu.rematchRecipes.allFailed',
{ total: p.total, recipes, entries, failures },
`Rematch failed for ${failures} of ${p.total} recipes.`
));
showToast('globalContextMenu.rematchRecipes.allFailed', { total: p.total, recipes, entries, failures }, 'error');
} else if (unresolved > 0) {
// Entries existed but have no local model —
// expected for models deleted from Civitai;
// informational, not an error.
const unresolvedRecipes = p.unresolved_recipes ?? 0;
progressUI?.complete(translate(
'globalContextMenu.rematchRecipes.noMatch',
{ entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures },
`No local match found for ${unresolved} entries in ${unresolvedRecipes} recipes.`
));
showToast('globalContextMenu.rematchRecipes.noMatch', { entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures }, 'info');
} else {
// Everything was skipped (nothing to do).
const l4Matches = Array.isArray(p.l4_matches) ? p.l4_matches : [];
// Complete no-op (nothing matched, nothing
// unresolved, no errors) keeps the lightweight
// toast; anything else opens the post-run summary
// modal.
const isNoop = entries === 0 && unresolved === 0 && failures === 0;
if (isNoop) {
progressUI?.complete(translate(
'globalContextMenu.rematchRecipes.success',
{ count: recipes, recipes, entries, failures },
`Matched ${entries} entries across ${recipes} recipes.`
));
showToast('globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, 'success');
} else {
progressUI?.complete();
showRematchSummary({
scope: 'global',
total: p.total || 0,
matchedRecipes: recipes,
matchedEntries: entries,
unresolvedRecipes: p.unresolved_recipes ?? 0,
unresolvedEntries: unresolved,
skipped: p.skipped || 0,
errors: failures,
l4Matches,
});
}
// Refresh recipes page if active
if (window.recipesPage) {
@@ -570,7 +468,23 @@ export class GlobalContextMenu extends BaseContextMenu {
{ count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
`Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).`
));
showToast('globalContextMenu.rematchRecipes.cancelled', { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, 'info');
// A cancelled run still reports partial results
// via the summary modal (marked as cancelled).
showRematchSummary({
scope: 'global',
cancelled: true,
total: p.total || 0,
matchedRecipes: cancelledRecipes,
matchedEntries: cancelledEntries,
unresolvedRecipes: p.unresolved_recipes ?? 0,
unresolvedEntries: p.unresolved_entries ?? 0,
skipped: p.skipped || 0,
errors: p.errors || 0,
l4Matches: Array.isArray(p.l4_matches) ? p.l4_matches : [],
});
if (window.recipesPage) {
window.recipesPage.refresh();
}
}
} else if (progressResponse.status === 404) {
// Progress might have finished quickly and been cleaned up
@@ -1,8 +1,7 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { state } from '../../state/index.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax, showToast } from '../../utils/uiHelpers.js';
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js';
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
import { moveManager } from '../../managers/MoveManager.js';
@@ -27,16 +26,6 @@ export class LoraContextMenu extends BaseContextMenu {
this.updateEnrichMenuItem(card);
}
updateEnrichMenuItem(card) {
const enrichItem = this.menu?.querySelector('[data-action="enrich-hf-llm"]');
if (!enrichItem) return;
const hasHfUrl = !!card.dataset.hf_url;
enrichItem.classList.toggle('disabled', !hasHfUrl);
enrichItem.title = hasHfUrl
? ''
: 'Link this model to a HuggingFace repo first (Link Model \u2192 Link to HuggingFace)';
}
handleMenuAction(action, menuItem) {
// First try to handle with common actions
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
@@ -75,9 +64,6 @@ export class LoraContextMenu extends BaseContextMenu {
case 'refresh-metadata':
getModelApiClient().refreshSingleModelMetadata(this.currentCard.dataset.filepath);
break;
case 'enrich-hf-llm':
this.enrichWithAgent(this.currentCard.dataset.filepath);
break;
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath);
break;
@@ -87,68 +73,6 @@ export class LoraContextMenu extends BaseContextMenu {
}
}
async enrichWithAgent(filePath) {
const { agentManager } = await import('../../managers/AgentManager.js');
const configured = await agentManager.isLlmConfigured();
if (!configured) {
showToast('toast.agent.llmNotConfigured', {}, 'warning');
return;
}
agentManager.connect();
const progressUI = state.loadingManager.showEnhancedProgress(
'Enriching metadata with AI...'
);
function cleanupCallbacks() {
const pIdx = agentManager.progressCallbacks.indexOf(onProgress);
if (pIdx >= 0) agentManager.progressCallbacks.splice(pIdx, 1);
const cIdx = agentManager.completeCallbacks.indexOf(onComplete);
if (cIdx >= 0) agentManager.completeCallbacks.splice(cIdx, 1);
const eIdx = agentManager.errorCallbacks.indexOf(onError);
if (eIdx >= 0) agentManager.errorCallbacks.splice(eIdx, 1);
}
const onProgress = (data) => {
if (data.status === 'processing' && data.current_path && data.updated_data && Object.keys(data.updated_data).length > 0) {
if (state.virtualScroller?.updateSingleItem) {
state.virtualScroller.updateSingleItem(data.current_path, data.updated_data);
}
const pct = data.total > 0 ? Math.floor((data.processed / data.total) * 100) : 0;
const name = data.current_path.split('/').pop();
progressUI.updateProgress(pct, name, `Processing ${name}`);
}
};
agentManager.onProgress(onProgress);
const onComplete = (data) => {
cleanupCallbacks();
if (data.status === 'completed') {
progressUI.complete(data.summary || 'Enrich complete');
showToast('toast.agent.enrichComplete', { summary: data.summary || 'Done' }, 'success');
}
};
agentManager.onComplete(onComplete);
const onError = (data) => {
cleanupCallbacks();
state.loadingManager.hide();
showToast('toast.agent.enrichFailed', { error: data.error || 'Unknown error' }, 'error');
};
agentManager.onError(onError);
try {
await agentManager.executeSkill('enrich_hf_metadata', [filePath]);
} catch (error) {
cleanupCallbacks();
state.loadingManager.hide();
showToast('toast.agent.enrichFailed', { error: error.message }, 'error');
}
}
sendLoraToWorkflow(replaceMode) {
const card = this.currentCard;
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
@@ -278,6 +278,79 @@ export const ModelContextMenuMixin = {
setTimeout(() => urlInput.focus(), 50);
},
// HF metadata enrichment (AI agent) methods
updateEnrichMenuItem(card) {
const enrichItem = this.menu?.querySelector('[data-action="enrich-hf-llm"]');
if (!enrichItem) return;
const hasHfUrl = !!card.dataset.hf_url;
enrichItem.classList.toggle('disabled', !hasHfUrl);
enrichItem.title = hasHfUrl
? ''
: 'Link this model to a HuggingFace repo first (Link Model → Link to HuggingFace)';
},
async enrichWithAgent(filePath) {
const { agentManager } = await import('../../managers/AgentManager.js');
const configured = await agentManager.isLlmConfigured();
if (!configured) {
showToast('toast.agent.llmNotConfigured', {}, 'warning');
return;
}
agentManager.connect();
const progressUI = state.loadingManager.showEnhancedProgress(
'Enriching metadata with AI...'
);
function cleanupCallbacks() {
const pIdx = agentManager.progressCallbacks.indexOf(onProgress);
if (pIdx >= 0) agentManager.progressCallbacks.splice(pIdx, 1);
const cIdx = agentManager.completeCallbacks.indexOf(onComplete);
if (cIdx >= 0) agentManager.completeCallbacks.splice(cIdx, 1);
const eIdx = agentManager.errorCallbacks.indexOf(onError);
if (eIdx >= 0) agentManager.errorCallbacks.splice(eIdx, 1);
}
const onProgress = (data) => {
if (data.status === 'processing' && data.current_path && data.updated_data && Object.keys(data.updated_data).length > 0) {
if (state.virtualScroller?.updateSingleItem) {
state.virtualScroller.updateSingleItem(data.current_path, data.updated_data);
}
const pct = data.total > 0 ? Math.floor((data.processed / data.total) * 100) : 0;
const name = data.current_path.split('/').pop();
progressUI.updateProgress(pct, name, `Processing ${name}`);
}
};
agentManager.onProgress(onProgress);
const onComplete = (data) => {
cleanupCallbacks();
if (data.status === 'completed') {
progressUI.complete(data.summary || 'Enrich complete');
showToast('toast.agent.enrichComplete', { summary: data.summary || 'Done' }, 'success');
}
};
agentManager.onComplete(onComplete);
const onError = (data) => {
cleanupCallbacks();
state.loadingManager.hide();
showToast('toast.agent.enrichFailed', { error: data.error || 'Unknown error' }, 'error');
};
agentManager.onError(onError);
try {
await agentManager.executeSkill('enrich_hf_metadata', [filePath]);
} catch (error) {
cleanupCallbacks();
state.loadingManager.hide();
showToast('toast.agent.enrichFailed', { error: error.message }, 'error');
}
},
parseModelId(value) {
if (value === undefined || value === null || value === '') {
return null;
@@ -388,6 +461,9 @@ export const ModelContextMenuMixin = {
case 'link-hf':
this.showLinkHfModal();
return true;
case 'enrich-hf-llm':
this.enrichWithAgent(this.currentCard.dataset.filepath);
return true;
case 'set-nsfw':
this.showNSFWLevelSelector(null, null, this.currentCard);
return true;
@@ -6,6 +6,9 @@ import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js
import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js';
import { moveManager } from '../../managers/MoveManager.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js';
import { showRematchSummary } from '../RematchSummaryModal.js';
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
export class RecipeContextMenu extends BaseContextMenu {
constructor() {
@@ -93,10 +96,6 @@ export class RecipeContextMenu extends BaseContextMenu {
// Download missing LoRAs
this.downloadMissingLoRAs(recipeId);
break;
case 'repair':
// Repair recipe metadata
this.repairRecipe(recipeId);
break;
case 'rematch':
// Rematch recipe resources to local models
this.rematchRecipe(recipeId);
@@ -297,44 +296,6 @@ export class RecipeContextMenu extends BaseContextMenu {
}
}
// Repair recipe metadata
async repairRecipe(recipeId) {
if (!recipeId) {
showToast('recipes.contextMenu.repair.missingId', {}, 'error');
return;
}
try {
showToast('recipes.contextMenu.repair.starting', {}, 'info');
const response = await fetch(`/api/lm/recipe/${recipeId}/repair`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
if (result.repaired > 0) {
showToast('recipes.contextMenu.repair.success', {}, 'success');
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');
}
} else {
throw new Error(result.error || 'Repair failed');
}
} catch (error) {
console.error('Error repairing recipe:', error);
showToast('recipes.contextMenu.repair.failed', { message: error.message }, 'error');
}
}
async rematchRecipe(recipeId) {
if (!recipeId) {
showToast('toast.recipes.rematchFailed', { message: 'Missing recipe ID' }, 'error');
@@ -344,26 +305,36 @@ export class RecipeContextMenu extends BaseContextMenu {
// Capture before any await: the menu's click handler nulls currentCard
const filePath = this.currentCard?.dataset?.filepath;
// Collect options (relaxed matching) before starting anything; the
// run only begins when the user confirms the dialog.
rematchModalManager.showOptionsModal({
scope: 'single',
onConfirm: ({ relaxed }) => this._startRematchRecipe(recipeId, filePath, relaxed),
});
}
async _startRematchRecipe(recipeId, filePath, relaxed = false) {
try {
showToast('Rematching recipe to local models...', {}, 'info');
const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, {
method: 'POST'
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: !!relaxed }),
});
const result = await response.json();
if (result.success) {
const matchedEntries = result.matched_entries || result.rematched || 0;
const failures = result.errors || 0;
const unresolvedEntries = result.unresolved_entries || 0;
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
// Complete no-op (nothing matched, nothing unresolved, no
// errors) keeps the lightweight toast; anything else opens
// the post-run summary modal.
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
if (matchedEntries > 0) {
const toastKey = failures > 0
? 'toast.recipes.rematchCompleteErrors'
: 'toast.recipes.rematchComplete';
showToast(
toastKey,
{ rematched: matchedEntries, skipped: result.skipped || 0, total: 1, entries: matchedEntries, recipes: 1, failures },
failures > 0 ? 'warning' : 'success'
);
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
if (detailResponse.ok) {
const updatedRecipe = await detailResponse.json();
@@ -371,16 +342,22 @@ export class RecipeContextMenu extends BaseContextMenu {
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
}
}
} else if (result.unresolved_entries > 0) {
// Entries existed but have no local model — expected for
// models deleted from Civitai; informational, not an error.
showToast(
'toast.recipes.rematchUnmatched',
{ entries: result.unresolved_entries, recipes: 1, total: 1 },
'info'
);
} else {
}
if (isNoop) {
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
} else {
showRematchSummary({
scope: 'single',
total: 1,
matchedRecipes: result.matched_recipes || (matchedEntries > 0 ? 1 : 0),
matchedEntries,
unresolvedRecipes: result.unresolved_recipes || 0,
unresolvedEntries,
skipped: result.skipped || 0,
errors: failures,
l4Matches,
});
}
} else {
throw new Error(result.error || 'Rematch failed');
@@ -397,6 +374,24 @@ export class RecipeContextMenu extends BaseContextMenu {
return;
}
// Recipes imported from a CivitAI image page can carry incomplete
// metadata (0 LoRAs); the companion browser extension can re-import
// them with the full page data. Fall back to the native path whenever
// the extension is absent, unlicensed, or the delegation fails.
const recipeItem = state.virtualScroller?.items?.find(item => item?.id === recipeId);
const civitaiImage = getCivitaiImageInfo(recipeItem?.source_path);
if (civitaiImage) {
try {
const probe = await probeExtension();
if (probe?.supported && probe?.licenseValid) {
await this.reimportViaExtension(recipeId, civitaiImage, recipeItem?.title || '');
return;
}
} catch (error) {
console.warn('Extension re-import unavailable, using native path:', error);
}
}
state.loadingManager.showSimpleLoading('Re-importing recipe from source...');
try {
@@ -419,6 +414,34 @@ export class RecipeContextMenu extends BaseContextMenu {
showToast('recipes.contextMenu.reimport.failed', { message: error.message }, 'error');
}
}
// Re-import a single CivitAI-image recipe through the companion browser
// extension. Throws on delegation failure so the caller can fall back to
// the native path.
async reimportViaExtension(recipeId, civitaiImage, title) {
state.loadingManager.showSimpleLoading('Re-importing recipe via browser extension...');
try {
const { failed } = await delegateReimport([{
recipeId,
imageId: civitaiImage.imageId,
imageUrl: civitaiImage.imageUrl,
title,
}]);
state.loadingManager.hide();
if (failed > 0) {
showToast('recipes.contextMenu.reimport.failed', { message: 'Extension re-import failed' }, 'error');
} else {
showToast('toast.recipes.reimportSuccess', {}, 'success');
}
const { resetAndReload } = await import('../../api/recipeApi.js');
resetAndReload(false, { preserveScroll: false });
} catch (error) {
state.loadingManager.hide();
throw error;
}
}
}
// Mix in shared methods from ModelContextMenuMixin
+61 -37
View File
@@ -1,5 +1,5 @@
// Recipe Modal Component
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow, isUnresolvableDownloadError } from '../utils/uiHelpers.js';
import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { buildCivitaiUrl } from '../utils/civitaiUtils.js';
import { translate } from '../utils/i18nHelpers.js';
@@ -1078,8 +1078,9 @@ class RecipeModal {
// Mirror the checkpoint "broken" rule: deleted, an
// unresolvable hash, or a name-only remnant with no CivitAI
// identifiers at all cannot be fixed by downloading
// reconnecting a local LoRA is the only remediation.
// identifiers at all cannot be fixed by downloading, so no
// download button is offered. Reconnect is always available
// for missing entries (see renderLoraItemActions).
const needsReconnect = !existsLocally
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
@@ -1180,7 +1181,7 @@ class RecipeModal {
</div>
${actionsRow}
</div>
${needsReconnect ? `
${!existsLocally ? `
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
<div class="reconnect-instructions">
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
@@ -2853,11 +2854,7 @@ class RecipeModal {
* the model cannot be resolved never for transient transport errors.
*/
_isUnresolvableDownloadError(message) {
if (!message) {
return false;
}
const text = String(message).toLowerCase();
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
return isUnresolvableDownloadError(message);
}
getResourceCivitaiUrl(resource) {
@@ -2877,12 +2874,14 @@ class RecipeModal {
canDownloadLora(lora) {
if (!lora) return false;
const modelId = lora.modelId || lora.modelID || lora.model_id;
const versionId = lora.id || lora.modelVersionId;
// Direct download needs both identifiers; a hash alone is enough
// because downloadRecipeLora resolves it to a version on demand —
// the same fallback the bulk "download missing" flow uses.
return !!((modelId && versionId) || lora.hash);
// A bare CivitAI version id is enough: it uniquely pins the exact
// file, and downloadRecipeLora resolves the owning model id from the
// version on demand (the same fallback the bulk "download missing"
// flow uses). A hash alone is likewise sufficient. A model id without
// an exact version id is NOT enough — downloading the model's latest
// version could silently mismatch the recipe's pinned version.
return !!(versionId || lora.hash);
}
renderCivitaiLink(url) {
@@ -2913,19 +2912,9 @@ class RecipeModal {
}
const controls = [];
if (needsReconnect) {
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
controls.push(`
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
<i class="fas fa-link" aria-hidden="true"></i>
<span>${escapeHtml(reconnectLabel)}</span>
</button>
`);
} else {
if (!needsReconnect) {
// needsReconnect already implies canDownloadLora() here, so the
// download action is unconditional.
// download action is unconditional in this branch.
const downloadLabel = translate('recipes.resources.download', {}, 'Download');
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
controls.push(`
@@ -2936,6 +2925,18 @@ class RecipeModal {
</button>
`);
}
// Reconnect is always offered for missing entries — when the LoRA
// already exists locally under a different hash, downloading first
// just to flip the button would be a waste.
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
controls.push(`
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
<i class="fas fa-link" aria-hidden="true"></i>
<span>${escapeHtml(reconnectLabel)}</span>
</button>
`);
return `<div class="recipe-lora-actions">${controls.join('')}</div>`;
}
@@ -2991,6 +2992,9 @@ class RecipeModal {
* Resolve the Civitai model/version identifiers needed for download.
* Recipe LoRAs parsed from PNG metadata often carry only a hash; resolve
* it through the same endpoint the bulk "download missing" flow uses.
* Version-only entries (page-imported recipes whose CivitAI version has
* no sha256) are resolved through the version endpoint, which returns
* the owning model id.
*/
async resolveLoraDownloadIdentifiers(lora) {
let modelId = lora.modelId || lora.modelID || lora.model_id;
@@ -3001,21 +3005,41 @@ class RecipeModal {
return { modelId, versionId, versionName };
}
if (!lora.hash) {
return null;
// Hash-only entries (PNG/recipe-JSON imports): resolve the owning
// model/version through the same endpoint the bulk "download
// missing" flow uses.
if (lora.hash) {
const response = await fetch(`/api/lm/loras/civitai/model/hash/${lora.hash}`);
const versionInfo = await response.json();
if (versionInfo?.error) {
return null;
}
modelId = versionInfo.modelId || versionInfo.model?.id;
versionId = versionInfo.id;
versionName = versionInfo.name || versionName;
return modelId && versionId ? { modelId, versionId, versionName } : null;
}
const response = await fetch(`/api/lm/loras/civitai/model/hash/${lora.hash}`);
const versionInfo = await response.json();
if (versionInfo?.error) {
return null;
// Version-only entries (page-imported recipes whose CivitAI versions
// expose no sha256): the version id still pins the exact file, so
// resolve the owning model id from the version endpoint on demand.
if (versionId) {
const response = await fetch(`/api/lm/loras/civitai/model/version/${versionId}`);
const versionInfo = await response.json();
if (!versionInfo || versionInfo?.error === 'Model not found') {
return null;
}
modelId = versionInfo.modelId || versionInfo.model?.id;
versionId = versionInfo.id || versionId;
versionName = versionInfo.name || versionName;
return modelId && versionId ? { modelId, versionId, versionName } : null;
}
modelId = versionInfo.modelId || versionInfo.model?.id;
versionId = versionInfo.id;
versionName = versionInfo.name || versionName;
return modelId && versionId ? { modelId, versionId, versionName } : null;
return null;
}
/**
+338
View File
@@ -0,0 +1,338 @@
import { translate } from '../utils/i18nHelpers.js';
import { showToast } from '../utils/uiHelpers.js';
/**
* Escape HTML entities in a string to prevent injection when interpolating
* into innerHTML (same approach as DownloadBatchSummaryModal).
* @param {string} str - The string to escape
* @returns {string} - The escaped string
*/
function _escapeHtml(str) {
if (str === null || str === undefined) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* Resolve the 3-state summary header (mirrors the batch download/import
* summary semantics).
*
* - error: nothing matched and at least one recipe errored
* - warning: errors, unresolved entries, filename-level (L4) matches to
* review, or a cancelled run
* - success: otherwise
*/
function _resolveHeader({ matchedEntries, errors, unresolvedEntries, l4Count, cancelled }) {
if (matchedEntries === 0 && errors > 0) {
return {
state: 'error',
icon: 'fa-times-circle',
text: translate('modals.rematchSummary.failed', {}, 'Rematch failed'),
};
}
if (errors > 0 || unresolvedEntries > 0 || l4Count > 0 || cancelled) {
return {
state: 'warning',
icon: 'fa-exclamation-circle',
text: translate('modals.rematchSummary.completedWithWarnings', {}, 'Rematch completed — review recommended'),
};
}
return {
state: 'success',
icon: 'fa-check-circle',
text: translate('modals.rematchSummary.successMessage', { entries: matchedEntries }, `Matched ${matchedEntries} entries`),
};
}
/**
* Build a plain-text report of the rematch run. `undoneIndexes` carries the
* L4 rows undone so far, so the report reflects the undo status at copy time.
*/
function _buildReportText({ scope, cancelled, total, matchedRecipes, matchedEntries, unresolvedRecipes, unresolvedEntries, skipped, errors, l4Matches, undoneIndexes }) {
const scopeFallbacks = {
global: 'All recipes',
bulk: 'Selected recipes',
single: 'Single recipe',
};
const scopeLabel = translate(
`modals.rematchSummary.scope_${scope}`,
{},
scopeFallbacks[scope] || scope
);
const lines = [
'=== Recipe Rematch Report ===',
`Date: ${new Date().toLocaleString()}`,
`Scope: ${scopeLabel}`,
`Cancelled: ${cancelled ? 'yes' : 'no'}`,
`Total recipes: ${total}`,
`Matched recipes: ${matchedRecipes}`,
`Matched entries: ${matchedEntries}`,
`Needs review (filename matches): ${l4Matches.length}`,
`Unresolved entries: ${unresolvedEntries} (in ${unresolvedRecipes} recipes)`,
`Skipped: ${skipped}`,
`Errors: ${errors}`,
'',
];
if (l4Matches.length > 0) {
lines.push('--- Filename matches (L4) ---');
l4Matches.forEach((match, i) => {
const undone = undoneIndexes.has(i) ? ' [undone]' : '';
lines.push(`${i + 1}. [${match.recipe_id}] ${match.entry} -> ${match.file_name}${undone}`);
});
lines.push('');
}
lines.push('====================');
return lines.join('\n');
}
/**
* Handle a successful clipboard write: confirm via toast and briefly swap the
* trigger button to a "Copied!" state (mirrors the batch summary modal).
*/
function _onCopyReportSuccess(btn) {
showToast('toast.api.copiedToClipboard', {}, 'success');
if (btn) {
const origHTML = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
}
}
/**
* Fallback for environments without the async Clipboard API (e.g. insecure
* contexts over LAN http): copy via a hidden textarea and execCommand.
*/
function _copyReportWithExecCommand(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('toast.api.copiedToClipboard', {}, 'success');
}
function _copyReport(btn, reportArgs) {
const text = _buildReportText(reportArgs);
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
navigator.clipboard.writeText(text)
.then(() => _onCopyReportSuccess(btn))
.catch(() => _copyReportWithExecCommand(text));
} else {
_copyReportWithExecCommand(text);
}
}
/**
* Undo a single L4 match via the existing restore endpoints (moved from
* RematchModalManager). Checkpoint restore needs only recipe_id; lora
* restore additionally takes lora_index.
*/
async function _undoMatch(match) {
const isCheckpoint = match.type === 'checkpoint';
const body = isCheckpoint
? { recipe_id: match.recipe_id }
: { recipe_id: match.recipe_id, lora_index: match.lora_index };
const response = await fetch(
isCheckpoint
? '/api/lm/recipe/checkpoint/restore'
: '/api/lm/recipe/lora/restore',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Restore failed');
}
}
/**
* Show the post-run rematch summary modal. Mirrors the batch download
* summary lifecycle: the modal element is appended directly to
* document.body and removed on close; it is not registered with
* ModalManager.
*
* @param {Object} options
* @param {'global'|'bulk'|'single'} options.scope - Which entry point ran
* @param {boolean} options.cancelled - Whether the run was cancelled
* @param {number} options.total - Recipes scanned
* @param {number} options.matchedRecipes - Recipes updated
* @param {number} options.matchedEntries - Entries reconnected
* @param {number} options.unresolvedRecipes - Recipes with unresolved entries
* @param {number} options.unresolvedEntries - Candidate entries with no local match
* @param {number} options.skipped - Recipes left untouched
* @param {number} options.errors - Per-recipe errors
* @param {Array} options.l4Matches - Filename-level matches for review/undo
* ({ recipe_id, type, entry, file_name, lora_index? })
*/
export function showRematchSummary({
scope = 'global',
cancelled = false,
total = 0,
matchedRecipes = 0,
matchedEntries = 0,
unresolvedRecipes = 0,
unresolvedEntries = 0,
skipped = 0,
errors = 0,
l4Matches = [],
} = {}) {
const matches = Array.isArray(l4Matches) ? l4Matches : [];
const undoneIndexes = new Set();
const header = _resolveHeader({
matchedEntries,
errors,
unresolvedEntries,
l4Count: matches.length,
cancelled,
});
const matchRows = matches.map((match, i) => `
<tr data-l4-index="${i}">
<td class="failure-index">${i + 1}</td>
<td class="failure-name" title="${_escapeHtml(match.recipe_id)}">${_escapeHtml(match.recipe_id)}</td>
<td class="failure-name" title="${_escapeHtml(match.entry)}">${_escapeHtml(match.entry)}</td>
<td class="failure-name" title="${_escapeHtml(match.file_name)}">${_escapeHtml(match.file_name)}</td>
<td class="rematch-undo-cell">
<button class="secondary-btn rematch-undo-btn" data-action="undo-match" data-index="${i}">
${translate('modals.rematchResults.undo', {}, 'Undo')}
</button>
</td>
</tr>`).join('');
const modalHtml = `
<div id="rematchSummaryModal" class="modal" style="display: block;">
<div class="modal-content rematch-summary-modal">
<button class="close" data-action="close-modal">&times;</button>
<h2>${translate('modals.rematchSummary.title', {}, 'Rematch Summary')}</h2>
<div class="summary-header ${header.state}">
<i class="fas ${header.icon}"></i>
<span class="summary-title">${header.text}</span>
<span class="summary-hint">${matchedRecipes}/${total}</span>
</div>
${cancelled ? `
<p class="rematch-cancelled-note">
<i class="fas fa-info-circle"></i>
${translate('modals.rematchSummary.cancelledNote', {}, 'Run cancelled before completion — counts are partial.')}
</p>` : ''}
<div class="refresh-summary-stats">
<div class="stat-card stat-card-success">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.rematchSummary.statMatched', {}, 'Matched entries')}</span>
<span class="stat-card-value">${matchedEntries}</span>
</div>
</div>
<div class="stat-card stat-card-skipped">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.rematchSummary.statReview', {}, 'Needs review')}</span>
<span class="stat-card-value">${matches.length}</span>
</div>
</div>
<div class="stat-card stat-card-total">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.rematchSummary.statUnresolved', {}, 'Unresolved')}</span>
<span class="stat-card-value">${unresolvedEntries}</span>
</div>
</div>
<div class="stat-card stat-card-failure">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.rematchSummary.statErrors', {}, 'Errors')}</span>
<span class="stat-card-value">${errors}</span>
</div>
</div>
</div>
${matches.length > 0 ? `
<div class="refresh-failures-section rematch-review-section">
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.rematchSummary.reviewSection', { count: matches.length }, `Filename matches to review (${matches.length})`)}</h4>
<div class="failure-table-wrapper">
<table class="failure-table">
<thead>
<tr>
<th>#</th>
<th>${translate('modals.rematchSummary.columnRecipe', {}, 'Recipe')}</th>
<th>${translate('modals.rematchSummary.columnEntry', {}, 'Entry')}</th>
<th>${translate('modals.rematchSummary.columnFile', {}, 'Matched file')}</th>
<th>${translate('modals.rematchSummary.columnUndo', {}, 'Undo')}</th>
</tr>
</thead>
<tbody>${matchRows}</tbody>
</table>
</div>
</div>
` : ''}
<div class="modal-actions">
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.rematchSummary.copyReport', {}, 'Copy Report')}</button>
<button class="cancel-btn" data-action="close-modal">${translate('modals.rematchSummary.close', {}, 'Close')}</button>
</div>
</div>
</div>
`;
const existing = document.getElementById('rematchSummaryModal');
if (existing) existing.remove();
const container = document.createElement('div');
container.innerHTML = modalHtml;
const modal = container.firstElementChild;
document.body.appendChild(modal);
const reportArgs = {
scope,
cancelled,
total,
matchedRecipes,
matchedEntries,
unresolvedRecipes,
unresolvedEntries,
skipped,
errors,
l4Matches: matches,
undoneIndexes,
};
modal.addEventListener('click', async (e) => {
const actionEl = e.target.closest('[data-action]');
const action = actionEl?.dataset.action;
if (!action) return;
e.preventDefault();
switch (action) {
case 'close-modal':
modal.remove();
break;
case 'copy-report':
_copyReport(actionEl, reportArgs);
break;
case 'undo-match': {
const index = Number(actionEl.dataset.index);
const match = matches[index];
if (!match || actionEl.disabled) break;
const row = modal.querySelector(`tr[data-l4-index="${index}"]`);
try {
await _undoMatch(match);
undoneIndexes.add(index);
row?.classList.add('undone');
actionEl.disabled = true;
actionEl.textContent = translate('modals.rematchResults.undone', {}, 'Undone');
} catch (error) {
console.error('Failed to undo rematch match:', error);
showToast(
'modals.rematchResults.undoFailed',
{ message: error.message },
'error'
);
}
break;
}
}
});
}
@@ -1390,8 +1390,22 @@ export function initVersionsTab({
try {
const client = ensureClient();
const rootsData = await client.fetchModelRoots();
const roots = rootsData?.roots;
// On the checkpoints page a diffusion model lives under the unet
// roots, so both root sets are needed to locate the current file.
let roots;
if (modelType === 'checkpoints') {
const [checkpointRoots, unetRoots] = await Promise.all([
client.fetchModelRoots(),
client.fetchModelRoots('diffusion_model'),
]);
roots = [
...(checkpointRoots?.roots || []),
...(unetRoots?.roots || []),
];
} else {
const rootsData = await client.fetchModelRoots();
roots = rootsData?.roots;
}
if (!Array.isArray(roots) || roots.length === 0) {
return null;
}
+2
View File
@@ -7,6 +7,7 @@ import { HeaderManager } from './components/Header.js';
import { settingsManager } from './managers/SettingsManager.js';
import { moveManager } from './managers/MoveManager.js';
import { bulkManager } from './managers/BulkManager.js';
import { rematchModalManager } from './managers/RematchModalManager.js';
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
import { helpManager } from './managers/HelpManager.js';
import { doctorManager } from './managers/DoctorManager.js';
@@ -68,6 +69,7 @@ export class AppCore {
window.doctorManager = doctorManager;
window.moveManager = moveManager;
window.bulkManager = bulkManager;
window.rematchModalManager = rematchModalManager;
// Initialize UI components
window.headerManager = new HeaderManager();
+23 -16
View File
@@ -18,6 +18,7 @@ export class BatchImportManager {
this.results = null;
this.isCancelled = false;
this.isImporting = false;
this.currentParentPath = null;
}
/**
@@ -718,9 +719,10 @@ export class BatchImportManager {
browser.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
// Load initial directory when opening
// Load initial directory when opening. An empty path lets the
// server pick its default (user home); "/" would be POSIX-only.
const currentPath = document.getElementById('batchDirectoryInput').value;
this.loadDirectory(currentPath || '/');
this.loadDirectory(currentPath || '');
}
}
}
@@ -761,6 +763,10 @@ export class BatchImportManager {
const directoryCount = document.getElementById('batchDirectoryCount');
const imageCount = document.getElementById('batchImageCount');
// Remember the server-computed parent path so the "up" navigation
// works with Windows paths too (they cannot be split on "/").
this.currentParentPath = data.parent_path || null;
if (currentPathEl) {
currentPathEl.textContent = data.current_path;
}
@@ -811,11 +817,9 @@ export class BatchImportManager {
`;
item.addEventListener('click', () => {
if (isParent) {
this.navigateToParentDirectory();
} else {
this.loadDirectory(path);
}
// The parent entry uses the server-provided parent_path (or the
// Windows drive-list token) directly — both are plain load targets.
this.loadDirectory(path);
});
return item;
@@ -839,15 +843,12 @@ export class BatchImportManager {
}
/**
* Navigate to parent directory
* Navigate to parent directory using the path reported by the server.
* Deriving it client-side by splitting on "/" breaks Windows paths.
*/
navigateToParentDirectory() {
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
if (currentPath) {
// Get parent path using path manipulation
const lastSeparator = currentPath.lastIndexOf('/');
const parentPath = lastSeparator > 0 ? currentPath.substring(0, lastSeparator) : currentPath;
this.loadDirectory(parentPath);
if (this.currentParentPath) {
this.loadDirectory(this.currentParentPath);
}
}
@@ -857,8 +858,14 @@ export class BatchImportManager {
selectCurrentDirectory() {
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
const directoryInput = document.getElementById('batchDirectoryInput');
if (currentPath && directoryInput) {
if (!currentPath) {
// Virtual levels (e.g. the Windows drive list) have no path.
showToast('toast.recipes.batchImportNoDirectory', {}, 'error');
return;
}
if (directoryInput) {
directoryInput.value = currentPath;
this.toggleDirectoryBrowser(); // Close browser
showToast('toast.recipes.batchImportDirectorySelected', { path: currentPath }, 'success');
+92 -103
View File
@@ -3,6 +3,8 @@ import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEm
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { rematchModalManager } from './RematchModalManager.js';
import { showRematchSummary } from '../components/RematchSummaryModal.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
@@ -10,6 +12,7 @@ import { createBaseModelPicker, inferBaseModelsFromFilepaths } from '../componen
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
import { eventManager } from '../utils/EventManager.js';
import { translate } from '../utils/i18nHelpers.js';
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../utils/extensionReimportBridge.js';
import { getNsfwLevelSelector } from '../components/shared/NsfwLevelSelector.js';
export class BulkManager {
@@ -103,7 +106,6 @@ export class BulkManager {
skipMetadataRefresh: false,
setFavorite: true,
unfavorite: true,
repairMetadata: true,
reimportMetadata: true,
rematchMetadata: true
}
@@ -858,17 +860,74 @@ export class BulkManager {
`Re-importing recipe 1/${total}...`
);
// Partition the selection: recipes sourced from a CivitAI image page
// can be delegated to the companion browser extension (which scrapes
// the full page metadata); everything else uses the native endpoint.
const delegatable = [];
const nativeFilePaths = [];
for (const filePath of filePaths) {
const recipeItem = recipeMap.get(filePath);
const civitaiImage = getCivitaiImageInfo(recipeItem?.source_path);
if (civitaiImage && recipeItem?.id) {
delegatable.push({
filePath,
recipeId: recipeItem.id,
imageId: civitaiImage.imageId,
imageUrl: civitaiImage.imageUrl,
title: recipeItem.title || '',
});
} else {
nativeFilePaths.push(filePath);
}
}
// Probe once; on any probe/delegate failure the delegatable recipes
// fall back to the native sequential loop below.
if (delegatable.length > 0) {
try {
const probe = await probeExtension();
if (probe?.supported && probe?.licenseValid) {
const batchResult = await delegateReimport(
delegatable.map(({ recipeId, imageId, imageUrl, title }) => ({
recipeId, imageId, imageUrl, title,
})),
{
onProgress: (progress) => {
progressUI.updateProgress(
Math.floor(((progress.current || 0) / total) * 100),
progress.title || '',
translate('toast.recipes.reimportingViaExtension', {
current: progress.current || 0,
total,
})
);
},
}
);
completed += batchResult.completed;
failed += batchResult.failed;
} else {
nativeFilePaths.push(...delegatable.map(entry => entry.filePath));
}
} catch (error) {
console.warn('[reimportSelectedRecipes] extension delegation failed, using native path:', error);
nativeFilePaths.push(...delegatable.map(entry => entry.filePath));
}
}
try {
for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
const processedBeforeNative = completed + failed;
for (let i = 0; i < nativeFilePaths.length; i++) {
const filePath = nativeFilePaths[i];
const recipeItem = recipeMap.get(filePath);
const recipeId = recipeItem?.id;
const recipeName = recipeItem?.title || recipeId || 'Unknown';
const processed = processedBeforeNative + i;
progressUI.updateProgress(
Math.floor((i / total) * 100),
Math.floor((processed / total) * 100),
recipeName,
`Re-importing recipe ${Math.min(i + 1, total)}/${total}...`
`Re-importing recipe ${Math.min(processed + 1, total)}/${total}...`
);
if (!recipeId) {
@@ -910,76 +969,6 @@ export class BulkManager {
}
}
async repairSelectedRecipes() {
if (state.selectedModels.size === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
return;
}
if (state.currentPageType !== 'recipes') {
showToast('This operation is only available for recipes', {}, 'warning');
return;
}
try {
const apiClient = this.getActiveApiClient();
const filePaths = Array.from(state.selectedModels);
if (typeof apiClient.repairBulkModels !== 'function') {
showToast('Bulk repair is not supported for this model type', {}, 'error');
return;
}
state.loadingManager.showSimpleLoading('Repairing recipe metadata...');
const result = await apiClient.repairBulkModels(filePaths);
if (result.success) {
const total = result.total || filePaths.length;
const repaired = result.repaired || 0;
const skipped = result.skipped || 0;
const recipes = result.recipes || [];
for (const recipe of recipes) {
if (recipe.file_path) {
state.virtualScroller.updateSingleItem(
recipe.file_path,
recipe
);
}
}
if (repaired > 0) {
showToast(
'toast.recipes.repairBulkComplete',
{ repaired, skipped, total },
'success'
);
} else {
showToast(
'toast.recipes.repairBulkSkipped',
{ total },
'info'
);
}
if (state.bulkMode) this.toggleBulkMode();
} else {
throw new Error(result.error || 'Bulk repair failed');
}
} catch (error) {
console.error('Error during bulk recipe repair:', error);
showToast('toast.recipes.repairBulkFailed', { message: error.message }, 'error');
} finally {
if (state.loadingManager?.hide) {
state.loadingManager.hide();
}
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
state.loadingManager.restoreProgressBar();
}
}
}
async rematchSelectedRecipes() {
if (state.selectedModels.size === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
@@ -991,6 +980,15 @@ export class BulkManager {
return;
}
// Collect options (relaxed matching) before starting anything; the
// run only begins when the user confirms the dialog.
rematchModalManager.showOptionsModal({
recipeCount: state.selectedModels.size,
onConfirm: ({ relaxed }) => this._startRematchSelectedRecipes(relaxed),
});
}
async _startRematchSelectedRecipes(relaxed = false) {
try {
const apiClient = this.getActiveApiClient();
const filePaths = Array.from(state.selectedModels);
@@ -1002,7 +1000,7 @@ export class BulkManager {
state.loadingManager.showSimpleLoading('Rematching recipes to local models...');
const result = await apiClient.rematchBulkModels(filePaths);
const result = await apiClient.rematchBulkModels(filePaths, { relaxed: !!relaxed });
if (result.success) {
const total = result.total || filePaths.length;
@@ -1028,38 +1026,29 @@ export class BulkManager {
}
}
if (matchedEntries > 0) {
const hasFailures = failures > 0;
const toastKey = hasFailures
? 'toast.recipes.rematchCompleteErrors'
: 'toast.recipes.rematchComplete';
showToast(
toastKey,
{ rematched, skipped, total, entries: matchedEntries, recipes: matchedRecipes, failures },
hasFailures ? 'warning' : 'success'
);
} else if (failures > 0) {
// Nothing matched and at least one recipe errored —
// "no rematch needed" would be actively misleading here.
showToast(
'toast.recipes.rematchAllFailed',
{ total, failures },
'error'
);
} else if (unresolvedEntries > 0) {
// Entries existed but have no local model — expected for
// models deleted from Civitai; informational, not an error.
showToast(
'toast.recipes.rematchUnmatched',
{ entries: unresolvedEntries, recipes: unresolvedRecipes, total },
'info'
);
} else {
// Complete no-op (nothing matched, nothing unresolved, no
// errors) keeps the lightweight toast; anything else opens
// the post-run summary modal.
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
if (isNoop) {
showToast(
'toast.recipes.rematchSkipped',
{ total },
'info'
);
} else {
showRematchSummary({
scope: 'bulk',
total,
matchedRecipes,
matchedEntries,
unresolvedRecipes,
unresolvedEntries,
skipped,
errors: failures,
l4Matches,
});
}
if (state.bulkMode) this.toggleBulkMode();
@@ -1,7 +1,9 @@
import { showToast } from '../utils/uiHelpers.js';
import { isUnresolvableDownloadError } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { extractRecipeId } from '../api/recipeApi.js';
import { state } from '../state/index.js';
import { modalManager } from './ModalManager.js';
@@ -13,6 +15,7 @@ export class BulkMissingLoraDownloadManager {
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
this.pendingLoras = [];
this.pendingRecipes = [];
this.pendingMissingByRecipe = null;
}
/**
@@ -136,6 +139,7 @@ export class BulkMissingLoraDownloadManager {
// Execute download
await this.executeDownload(this.pendingLoras);
this.pendingLoras = [];
this.pendingMissingByRecipe = null;
}
/**
@@ -153,6 +157,9 @@ export class BulkMissingLoraDownloadManager {
// Collect missing LoRAs with deduplication
const stats = this.collectMissingLoras(selectedRecipes);
// Kept so executeDownload can mark unresolvable failures back onto
// every recipe occurrence (hashInvalid → reconnect candidacy).
this.pendingMissingByRecipe = stats.missingLorasByRecipe;
if (stats.uniqueCount === 0) {
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
@@ -196,6 +203,7 @@ export class BulkMissingLoraDownloadManager {
let completedDownloads = 0;
let failedDownloads = 0;
let markedInvalidCount = 0;
let currentLoraProgress = 0;
let cancelled = false;
@@ -304,6 +312,12 @@ export class BulkMissingLoraDownloadManager {
if (!response.success) {
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
failedDownloads++;
// An unresolvable failure (model gone on CivitAI) flips
// every recipe occurrence to reconnect candidacy — same
// rule as the single-LoRA download in RecipeModal.
if (isUnresolvableDownloadError(response.error)) {
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
}
} else {
completedDownloads++;
updateProgress(100, completedDownloads, '');
@@ -312,6 +326,9 @@ export class BulkMissingLoraDownloadManager {
if (!cancelled) {
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
failedDownloads++;
if (isUnresolvableDownloadError(error?.message)) {
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
}
}
}
}
@@ -335,9 +352,16 @@ export class BulkMissingLoraDownloadManager {
}, 'warning');
}
// Unresolvable failures were marked hash-invalid during the loop;
// tell the user those entries now offer reconnect instead of download.
if (markedInvalidCount > 0) {
showToast('toast.recipes.unresolvableMarkedForReconnect', {
count: markedInvalidCount
}, 'info', `${markedInvalidCount} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.`);
}
// 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;
@@ -354,6 +378,59 @@ export class BulkMissingLoraDownloadManager {
}
}
/**
* Mark every recipe occurrence of a failed LoRA as hash-invalid.
*
* Mirrors RecipeModal.markLoraHashInvalid for the bulk flow: the flag
* makes each occurrence an unresolved rematch candidate and swaps its
* action from download to reconnect. Only called for unresolvable
* failures transient errors leave entries untouched.
*
* @param {Object} failedLora - The deduplicated LoRA that failed
* @returns {Promise<number>} - How many recipe entries were marked
*/
async markLoraHashInvalidInRecipes(failedLora) {
const failedKey = failedLora.hash || failedLora.id || failedLora.modelVersionId;
if (!failedKey || !this.pendingMissingByRecipe) {
return 0;
}
let marked = 0;
for (const { recipe, missingLoras } of this.pendingMissingByRecipe.values()) {
const recipeId = extractRecipeId(recipe.file_path) || recipe.id;
if (!recipeId || !Array.isArray(recipe.loras)) {
continue;
}
for (const entry of missingLoras) {
const entryKey = entry.hash || entry.id || entry.modelVersionId;
if (entryKey !== failedKey) {
continue;
}
const loraIndex = recipe.loras.indexOf(entry);
if (loraIndex < 0) {
continue;
}
try {
const response = await fetch('/api/lm/recipe/lora/mark-hash-invalid', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipe_id: recipeId,
lora_index: loraIndex,
}),
});
if (response.ok) {
entry.hashInvalid = true;
marked++;
}
} catch (error) {
console.warn('Failed to mark LoRA hash invalid:', error);
}
}
}
return marked;
}
/**
* Get LoRA root directory from API
* @returns {Promise<string|null>} - LoRA root directory or null
+54 -8
View File
@@ -3,6 +3,7 @@ import { showToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { LoadingManager } from './LoadingManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { DOWNLOAD_ENDPOINTS } from '../api/apiConfig.js';
import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
@@ -489,8 +490,9 @@ export class DownloadManager {
return { type: 'civitai' };
}
// Hugging Face resolve URL → direct file
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/resolve\/([^/\s]+)\/(.+)/i);
// Hugging Face resolve/blob URL → direct file
// "blob" is the web preview page; it maps 1:1 to the "resolve" download URL
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/(?:resolve|blob)\/([^/\s]+)\/(.+)/i);
if (hfResolveMatch) {
return {
type: 'hf-resolve',
@@ -953,12 +955,7 @@ export class DownloadManager {
async proceedToLocationContent() {
try {
const _isDiffusionModel = this.selectedFile
? (this.selectedFile.type === 'UNet' || this.selectedFile.type === 'Diffusion Model')
: (this.currentVersion?.files || []).some(
f => f.type === 'UNet' || f.type === 'Diffusion Model'
);
this._isDiffusionModel = _isDiffusionModel;
this._isDiffusionModel = await this._resolveIsDiffusionModel();
let rootsData;
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
@@ -1019,6 +1016,55 @@ export class DownloadManager {
}
}
/**
* Decide whether this download routes to the diffusion model (unet)
* roots rather than the checkpoint roots. The backend owns the routing
* rule (file type first, baseModel fallback), so the location step asks
* it; if the endpoint is unavailable we degrade to the local file-type
* signal, which matches the backend for well-annotated models.
*/
async _resolveIsDiffusionModel() {
const localFileTypeCheck = this.selectedFile
? (this.selectedFile.type === 'UNet' || this.selectedFile.type === 'Diffusion Model')
: (this.currentVersion?.files || []).some(
f => f.type === 'UNet' || f.type === 'Diffusion Model'
);
// Only checkpoint downloads can route to the diffusion model roots;
// without version metadata (e.g. Hugging Face downloads) the local
// signal is all we have.
if (this.apiClient.modelType !== 'checkpoints'
|| (!this.selectedFile && !this.currentVersion)) {
return localFileTypeCheck;
}
try {
const fileTypes = this.selectedFile
? [this.selectedFile.type]
: (this.currentVersion?.files || []).map(f => f.type);
const response = await fetch(DOWNLOAD_ENDPOINTS.routing, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_type: 'checkpoint',
base_model: this.currentVersion?.baseModel || '',
file_types: fileTypes,
}),
});
if (!response.ok) {
throw new Error(`routing endpoint returned ${response.status}`);
}
const data = await response.json();
if (typeof data.is_diffusion_model === 'boolean') {
return data.is_diffusion_model;
}
} catch (error) {
console.warn('[download] routing endpoint unavailable, '
+ 'falling back to local file-type check:', error);
}
return localFileTypeCheck;
}
loadDefaultPathSetting() {
const modelType = this.apiClient.modelType;
const storageKey = `use_default_path_${modelType}`;
+13
View File
@@ -347,6 +347,19 @@ export class ModalManager {
});
}
// Register rematchOptionsModal
const rematchOptionsModal = document.getElementById('rematchOptionsModal');
if (rematchOptionsModal) {
this.registerModal('rematchOptionsModal', {
element: rematchOptionsModal,
onClose: () => {
this.getModal('rematchOptionsModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true;
}
+22 -10
View File
@@ -329,7 +329,11 @@ class MoveManager {
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
movedFiles = (results || [])
.filter(r => r.success)
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
.map(r => ({
original_file_path: r.original_file_path,
new_file_path: r.new_file_path,
sub_type: r.cache_entry?.sub_type
}));
// Deselect moving items and exit bulk mode
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
@@ -340,7 +344,11 @@ class MoveManager {
if (result) {
movedFiles.push({
original_file_path: result.original_file_path || this.currentFilePath,
new_file_path: result.new_file_path
new_file_path: result.new_file_path,
// The backend recalculates location-derived fields
// (e.g. checkpoint -> diffusion_model) during the move;
// carry them so the card re-renders with the new type.
sub_type: result.cache_entry?.sub_type
});
}
@@ -379,24 +387,28 @@ class MoveManager {
}
if (stillVisible) {
const newData = {
file_path: moved.new_file_path,
folder: newRelativeFolder
};
if (moved.sub_type) newData.sub_type = moved.sub_type;
pathsToUpdate.push({
originalPath: moved.original_file_path,
newData: {
file_path: moved.new_file_path,
folder: newRelativeFolder
}
newData
});
} else {
pathsToRemove.push(moved.original_file_path);
}
} else {
// No folder filter active — items remain visible, just update path
const newData = {
file_path: moved.new_file_path,
folder: this._getRelativeFolder(moved.new_file_path)
};
if (moved.sub_type) newData.sub_type = moved.sub_type;
pathsToUpdate.push({
originalPath: moved.original_file_path,
newData: {
file_path: moved.new_file_path,
folder: this._getRelativeFolder(moved.new_file_path)
}
newData
});
}
}
+72
View File
@@ -0,0 +1,72 @@
import { modalManager } from './ModalManager.js';
import { translate } from '../utils/i18nHelpers.js';
/**
* Owns the recipe-rematch options modal (rematchOptionsModal), shown BEFORE
* a global/bulk/single rematch run; collects the "relaxed matching" opt-in
* and only then invokes the run callback. Post-run reporting lives in
* static/js/components/RematchSummaryModal.js.
*/
export class RematchModalManager {
constructor() {
this._optionsConfirmCallback = null;
}
/**
* Open the options modal. `onConfirm({ relaxed })` fires only when the
* user clicks Rematch Cancel/X runs nothing.
*
* @param {{ scope?: 'global'|'bulk'|'single', recipeCount?: number|null, onConfirm?: function }} options
*/
showOptionsModal({ scope = null, recipeCount = null, onConfirm } = {}) {
const resolvedScope = scope || (recipeCount != null ? 'bulk' : 'global');
const message = document.getElementById('rematchOptionsMessage');
if (message) {
if (resolvedScope === 'bulk') {
message.textContent = translate(
'modals.rematchOptions.messageBulk',
{ count: recipeCount },
`${recipeCount} selected recipe(s) will be scanned against your local model library.`
);
} else if (resolvedScope === 'single') {
message.textContent = translate(
'modals.rematchOptions.messageSingle',
{},
'This recipe will be scanned against your local model library.'
);
} else {
message.textContent = translate(
'modals.rematchOptions.messageGlobal',
{},
'All recipes will be scanned against your local model library.'
);
}
}
const checkbox = document.getElementById('rematchOptionsRelaxed');
if (checkbox) {
checkbox.checked = false;
}
this._optionsConfirmCallback = typeof onConfirm === 'function' ? onConfirm : null;
modalManager.showModal('rematchOptionsModal');
}
confirmOptions() {
const checkbox = document.getElementById('rematchOptionsRelaxed');
const relaxed = checkbox ? !!checkbox.checked : false;
const callback = this._optionsConfirmCallback;
this._optionsConfirmCallback = null;
modalManager.closeModal('rematchOptionsModal');
if (callback) {
// Returned so callers (and tests) can await the started run.
return callback({ relaxed });
}
return undefined;
}
cancelOptions() {
this._optionsConfirmCallback = null;
modalManager.closeModal('rematchOptionsModal');
}
}
export const rematchModalManager = new RematchModalManager();
+10
View File
@@ -1047,6 +1047,12 @@ export class SettingsManager {
groupByModelCheckbox.checked = !!state.global.settings.group_by_model;
}
// Set sticky controls
const stickyControlsCheckbox = document.getElementById('stickyControls');
if (stickyControlsCheckbox) {
stickyControlsCheckbox.checked = !!state.global.settings.sticky_controls;
}
// Set model name display setting
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
if (modelNameDisplaySelect) {
@@ -3396,6 +3402,10 @@ export class SettingsManager {
const groupByModel = !!state.global.settings.group_by_model;
document.body.classList.toggle('group-by-model', groupByModel);
// Apply sticky controls mode (keeps the action bar visible while scrolling)
const stickyControls = !!state.global.settings.sticky_controls;
document.body.classList.toggle('sticky-controls', stickyControls);
}
}
+1
View File
@@ -59,6 +59,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
strip_lora_on_copy: false,
use_new_license_icons: true,
group_by_model: false,
sticky_controls: false,
llm_provider: 'openai',
llm_api_key: '',
llm_api_base: '',
+220
View File
@@ -0,0 +1,220 @@
/**
* Bridge to the companion LoRA Manager browser extension.
*
* The extension can re-import recipes sourced from CivitAI image pages with
* the complete page metadata (internal trpc data scraped with the user's
* session), fixing recipes that the native import (REST API + EXIF only)
* saved with 0 LoRAs.
*
* Protocol: DOM CustomEvents on `document`; `detail` is ALWAYS a JSON
* string on both sides.
*
* LM page -> extension: `lm:reimportProbe`, detail `{}`.
* extension -> LM page: `lm:reimportProbeResult`,
* detail `{supported, licenseValid, extensionVersion?, reason?}`.
* LM page -> extension: `lm:reimportViaExtension`,
* detail `{requestId, recipes: [{recipeId, imageId, imageUrl, title}]}`.
* extension -> LM page: `lm:reimportProgress`,
* detail `{requestId, current, total, recipeId, title, status, message?}`.
* extension -> LM page: `lm:reimportBatchDone`,
* detail `{requestId, completed, failed}`.
*/
const PROBE_EVENT = 'lm:reimportProbe';
const PROBE_RESULT_EVENT = 'lm:reimportProbeResult';
const REIMPORT_EVENT = 'lm:reimportViaExtension';
const PROGRESS_EVENT = 'lm:reimportProgress';
const BATCH_DONE_EVENT = 'lm:reimportBatchDone';
const DEFAULT_PROBE_TIMEOUT_MS = 500;
// Generous batch timeout; any progress event resets it (heartbeat).
const DEFAULT_REIMPORT_TIMEOUT_MS = 3 * 60 * 1000;
// Mirrors py/utils/civitai_utils.py (_SUPPORTED_CIVITAI_PAGE_HOSTS).
const SUPPORTED_CIVITAI_PAGE_HOSTS = new Set([
'civitai.com',
'civitai.red',
'civitai.green',
]);
/**
* Parse the JSON-string `detail` of a protocol event.
* @param {CustomEvent} event
* @returns {object|null} Parsed detail object, or null when absent/invalid.
*/
function parseDetail(event) {
try {
const detail = JSON.parse(event?.detail ?? 'null');
return detail && typeof detail === 'object' ? detail : null;
} catch {
return null;
}
}
/**
* Dispatch a protocol event with a JSON-stringified detail.
* @param {string} type - Event name.
* @param {object} payload - Detail payload (JSON-stringified).
*/
function dispatchProtocolEvent(type, payload) {
document.dispatchEvent(
new CustomEvent(type, { detail: JSON.stringify(payload ?? {}) })
);
}
/**
* Generate a correlation id for a re-import batch.
* @returns {string}
*/
function generateRequestId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `lm-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* Probe whether the companion extension is installed and usable.
*
* @param {{timeoutMs?: number}} [options]
* @returns {Promise<{supported: boolean, licenseValid: boolean, extensionVersion?: string, reason?: string}|null>}
* Resolves with the probe result, or null when the extension is absent or
* too old to answer (timeout).
*/
export function probeExtension({ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS } = {}) {
return new Promise((resolve) => {
let settled = false;
const timer = setTimeout(() => finish(null), timeoutMs);
const finish = (value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
document.removeEventListener(PROBE_RESULT_EVENT, onResult);
resolve(value);
};
const onResult = (event) => {
const detail = parseDetail(event);
if (!detail) return;
finish({
supported: Boolean(detail.supported),
licenseValid: Boolean(detail.licenseValid),
extensionVersion: detail.extensionVersion,
reason: detail.reason,
});
};
document.addEventListener(PROBE_RESULT_EVENT, onResult);
dispatchProtocolEvent(PROBE_EVENT, {});
});
}
/**
* Delegate a batch of recipe re-imports to the companion extension.
*
* @param {Array<{recipeId: string, imageId: number, imageUrl: string, title: string}>} recipes
* @param {{onProgress?: (progress: object) => void, timeoutMs?: number}} [options]
* @returns {Promise<{completed: number, failed: number}>} Resolves on
* `lm:reimportBatchDone`; rejects on timeout. Listeners are cleaned up in
* all outcomes.
*/
export function delegateReimport(recipes, { onProgress, timeoutMs = DEFAULT_REIMPORT_TIMEOUT_MS } = {}) {
return new Promise((resolve, reject) => {
if (!Array.isArray(recipes) || recipes.length === 0) {
reject(new Error('delegateReimport requires a non-empty recipe list'));
return;
}
const requestId = generateRequestId();
let settled = false;
let timer = null;
const cleanup = () => {
clearTimeout(timer);
document.removeEventListener(PROGRESS_EVENT, onProgressEvent);
document.removeEventListener(BATCH_DONE_EVENT, onBatchDone);
};
const succeed = (value) => {
if (settled) return;
settled = true;
cleanup();
resolve(value);
};
const fail = (error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const armTimer = () => {
clearTimeout(timer);
timer = setTimeout(
() => fail(new Error('Extension re-import timed out')),
timeoutMs
);
};
const onProgressEvent = (event) => {
const detail = parseDetail(event);
if (!detail || detail.requestId !== requestId) return;
// Heartbeat: any progress for this batch resets the timeout.
armTimer();
if (typeof onProgress === 'function') {
try {
onProgress(detail);
} catch (error) {
console.error('[extensionReimportBridge] onProgress callback failed:', error);
}
}
};
const onBatchDone = (event) => {
const detail = parseDetail(event);
if (!detail || detail.requestId !== requestId) return;
succeed({
completed: Number.isInteger(detail.completed) ? detail.completed : 0,
failed: Number.isInteger(detail.failed) ? detail.failed : 0,
});
};
document.addEventListener(PROGRESS_EVENT, onProgressEvent);
document.addEventListener(BATCH_DONE_EVENT, onBatchDone);
armTimer();
dispatchProtocolEvent(REIMPORT_EVENT, { requestId, recipes });
});
}
/**
* Extract CivitAI image page info from a recipe source_path.
* Mirrors py/utils/civitai_utils.py `extract_civitai_image_id`.
*
* @param {string|null} sourcePath - Recipe source_path.
* @returns {{imageId: number, imageUrl: string}|null} Null when the path is
* not a `/images/<id>` URL on civitai.com/.red/.green.
*/
export function getCivitaiImageInfo(sourcePath) {
if (!sourcePath || typeof sourcePath !== 'string') {
return null;
}
let parsed;
try {
parsed = new URL(sourcePath);
} catch {
return null;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
if (!SUPPORTED_CIVITAI_PAGE_HOSTS.has(parsed.hostname.toLowerCase())) {
return null;
}
const pathMatch = parsed.pathname.match(/\/images\/(\d+)/);
if (!pathMatch) {
return null;
}
return { imageId: Number(pathMatch[1]), imageUrl: sourcePath };
}
+17
View File
@@ -325,6 +325,23 @@ export function isTypingContext(target) {
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
}
/**
* Decide whether a download failure means the model is unrecoverable.
*
* The hash-invalid flag (and the resulting rematch/reconnect candidacy) is
* only set when CivitAI explicitly says the model cannot be resolved never
* for transient transport errors (network, 5xx).
* @param {*} message - The error message carried by the failed download
* @returns {boolean}
*/
export function isUnresolvableDownloadError(message) {
if (!message) {
return false;
}
const text = String(message).toLowerCase();
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
}
export function restoreFolderFilter() {
const activeFolder = getStorageItem('activeFolder');
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
+5
View File
@@ -25,6 +25,9 @@
</div>
</div>
</div>
<div class="context-menu-item" data-action="enrich-hf-llm">
<i class="fas fa-wand-magic-sparkles"></i> <span>{{ t('loras.contextMenu.enrichHfAgent') }}</span>
</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Workflow -->
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
@@ -54,8 +57,10 @@
{% endblock %}
{% block content %}
<div class="sticky-topbar">
{% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
</div>
{% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %}
-6
View File
@@ -94,9 +94,6 @@
<div class="context-menu-item" data-action="check-updates">
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
</div>
<div class="context-menu-item" data-action="repair-metadata">
<i class="fas fa-tools"></i> <span>{{ t('loras.bulkOperations.repairMetadata') }}</span> (Deprecated)
</div>
<div class="context-menu-item" data-action="rematch-metadata">
<i class="fas fa-link"></i> <span>{{ t('loras.bulkOperations.rematchMetadata') }}</span>
</div>
@@ -202,9 +199,6 @@
<i class="fas fa-layer-group"></i> <span>{{ t('globalContextMenu.groupByModel.label') }}</span>
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div>
<div class="context-menu-item" data-action="repair-recipes">
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span> (Deprecated)
</div>
<div class="context-menu-item" data-action="rematch-recipes">
<i class="fas fa-link"></i> <span>{{ t('globalContextMenu.rematchRecipes.label') }}</span>
</div>
@@ -125,4 +125,35 @@
</button>
</div>
</div>
</div>
</div>
<!-- Recipe Rematch Options Modal -->
<div id="rematchOptionsModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>{{ t('modals.rematchOptions.title') }}</h2>
<span class="close" onclick="rematchModalManager.cancelOptions()">&times;</span>
</div>
<div class="modal-body">
<p class="confirmation-message" id="rematchOptionsMessage"></p>
<label class="rematch-option-card" for="rematchOptionsRelaxed">
<input type="checkbox" id="rematchOptionsRelaxed">
<span class="rematch-option-checkmark" aria-hidden="true"></span>
<span class="rematch-option-text">
<span class="rematch-option-title">{{ t('modals.rematchOptions.relaxedLabel') }}</span>
<span class="rematch-option-caveat">
<i class="fas fa-info-circle" aria-hidden="true"></i>
{{ t('modals.rematchOptions.relaxedDescription') }}
</span>
</span>
</label>
</div>
<div class="modal-actions">
<button class="secondary-btn" onclick="rematchModalManager.cancelOptions()">{{ t('common.actions.cancel') }}</button>
<button class="primary-btn" id="rematchOptionsConfirmBtn" onclick="rematchModalManager.confirmOptions()">
<i class="fas fa-sync-alt"></i>
{{ t('modals.rematchOptions.confirmButton') }}
</button>
</div>
</div>
</div>
@@ -79,6 +79,9 @@
<!-- Group by model toggle -->
{{ sm.setting_toggle('groupByModel', 'group_by_model', 'settings.layoutSettings.groupByModel', 'settings.layoutSettings.groupByModelHelp') }}
<!-- Sticky controls toggle -->
{{ sm.setting_toggle('stickyControls', 'sticky_controls', 'settings.layoutSettings.stickyControls', 'settings.layoutSettings.stickyControlsHelp') }}
{{ sm.setting_select('cardInfoDisplay', 'card_info_display', 'settings.layoutSettings.cardInfoDisplay', [
('always', 'settings.layoutSettings.cardInfoDisplayOptions.always'),
('hover', 'settings.layoutSettings.cardInfoDisplayOptions.hover'),
+2
View File
@@ -53,8 +53,10 @@
{% endblock %}
{% block content %}
<div class="sticky-topbar">
{% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
</div>
{% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %}
+2
View File
@@ -8,8 +8,10 @@
{% block init_check_url %}/api/loras/list?page=1&page_size=1{% endblock %}
{% block content %}
<div class="sticky-topbar">
{% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
</div>
{% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %}
+3 -4
View File
@@ -18,9 +18,6 @@
<div id="recipeContextMenu" class="context-menu" style="display: none;">
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
<!-- Metadata -->
<div class="context-menu-item" data-action="repair">
<i class="fas fa-tools"></i> {{ t('loras.contextMenu.repairMetadata') }} (Deprecated)
</div>
<div class="context-menu-item" data-action="rematch">
<i class="fas fa-link"></i> {{ t('loras.contextMenu.rematchMetadata') }}
</div>
@@ -64,10 +61,12 @@
{% block init_check_url %}/api/recipes?page=1&page_size=1{% endblock %}
{% block content %}
<!-- Recipe controls -->
<!-- Sticky topbar: controls + breadcrumb -->
<div class="sticky-topbar">
{% include 'components/controls.html' %}
<!-- Breadcrumb Navigation -->
{% include 'components/breadcrumb.html' %}
</div>
<!-- Duplicates banner (hidden by default) -->
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
@@ -100,7 +100,7 @@ def evaluate_model(
flagged issues.
"""
civitai = metadata.get("civitai") or {}
trained_words: List[str] = civitai.get("trainedWords") or metadata.get("trainedWords") or []
trained_words: List[str] = civitai.get("trainedWords") or []
short_desc: str = civitai.get("description") or ""
tags: List[str] = metadata.get("tags") or []
notes: str = metadata.get("notes") or ""
@@ -149,7 +149,6 @@ def create_initial_metadata(
"metadata_source": "",
"last_checked_at": 0,
"hash_status": "completed",
"trainedWords": [],
"hf_url": hf_url,
"usage_tips": "{}",
}
+15
View File
@@ -309,6 +309,21 @@ describe('RecipeSidebarApiClient bulk operations', () => {
expect(global.fetch).not.toHaveBeenCalled();
});
it('includes relaxed in the bulk rematch body only when opted in', async () => {
const api = new RecipeSidebarApiClient();
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true, total: 1, rematched: 1, skipped: 0, errors: 0, recipes: [] }),
});
await api.rematchBulkModels(['/recipes/a.webp'], { relaxed: true });
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
recipe_ids: ['a'],
relaxed: true,
});
});
it('throws the backend error when bulk rematch fails', async () => {
const api = new RecipeSidebarApiClient();
global.fetch.mockResolvedValue({
@@ -0,0 +1,233 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const translateMock = vi.fn((key, params, fallback) => {
if (typeof fallback === 'string') {
// Apply {param} interpolation so counts remain assertable.
return Object.entries(params || {}).reduce(
(text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
fallback
);
}
return key;
});
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: translateMock,
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
async function getShowRematchSummary() {
const { showRematchSummary } = await import(
'../../../static/js/components/RematchSummaryModal.js'
);
return showRematchSummary;
}
const L4_LORA = { recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 };
const L4_CHECKPOINT = { recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' };
describe('RematchSummaryModal', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = '';
global.fetch = vi.fn();
});
afterEach(() => {
document.body.innerHTML = '';
delete global.fetch;
delete navigator.clipboard;
vi.restoreAllMocks();
});
it('renders a success header when everything matched cleanly', async () => {
const showRematchSummary = await getShowRematchSummary();
showRematchSummary({ scope: 'global', total: 10, matchedRecipes: 2, matchedEntries: 3 });
const modal = document.getElementById('rematchSummaryModal');
expect(modal).not.toBeNull();
expect(modal.querySelector('.summary-header').classList.contains('success')).toBe(true);
expect(modal.querySelector('.summary-title').textContent).toBe('Matched 3 entries');
expect(modal.querySelector('.failure-table')).toBeNull();
});
it('renders an error header when nothing matched and errors occurred', async () => {
const showRematchSummary = await getShowRematchSummary();
showRematchSummary({ scope: 'global', total: 3, errors: 3 });
const modal = document.getElementById('rematchSummaryModal');
expect(modal.querySelector('.summary-header').classList.contains('error')).toBe(true);
expect(modal.querySelector('.summary-title').textContent).toBe('Rematch failed');
});
it('renders a warning header for unresolved entries, L4 matches, or cancellations', async () => {
const showRematchSummary = await getShowRematchSummary();
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 1, unresolvedEntries: 1, unresolvedRecipes: 1 });
expect(document.querySelector('#rematchSummaryModal .summary-header').classList.contains('warning')).toBe(true);
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 2, l4Matches: [L4_LORA] });
expect(document.querySelector('#rematchSummaryModal .summary-header').classList.contains('warning')).toBe(true);
showRematchSummary({ scope: 'global', total: 5, matchedEntries: 2, cancelled: true });
const modal = document.getElementById('rematchSummaryModal');
expect(modal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(modal.querySelector('.rematch-cancelled-note')).not.toBeNull();
});
it('renders the four stat cards in order', async () => {
const showRematchSummary = await getShowRematchSummary();
showRematchSummary({
scope: 'bulk',
total: 4,
matchedRecipes: 1,
matchedEntries: 2,
unresolvedEntries: 3,
errors: 1,
l4Matches: [L4_LORA],
});
const modal = document.getElementById('rematchSummaryModal');
const values = Array.from(modal.querySelectorAll('.stat-card-value')).map(el => el.textContent);
expect(values).toEqual(['2', '1', '3', '1']);
const labels = Array.from(modal.querySelectorAll('.stat-card-label')).map(el => el.textContent);
expect(labels).toEqual(['Matched entries', 'Needs review', 'Unresolved', 'Errors']);
});
it('renders the L4 review table only when matches exist', async () => {
const showRematchSummary = await getShowRematchSummary();
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 2, l4Matches: [L4_LORA, L4_CHECKPOINT] });
const modal = document.getElementById('rematchSummaryModal');
const rows = modal.querySelectorAll('.failure-table tbody tr');
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain('r1');
expect(rows[0].textContent).toContain('old.safetensors');
expect(rows[0].textContent).toContain('new.safetensors');
expect(rows[1].textContent).toContain('cp-new.safetensors');
expect(modal.querySelectorAll('.rematch-undo-btn')).toHaveLength(2);
});
it('undo posts to the lora restore endpoint, then strikes and disables the row', async () => {
const showRematchSummary = await getShowRematchSummary();
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_LORA] });
const modal = document.getElementById('rematchSummaryModal');
const row = modal.querySelector('tr[data-l4-index="0"]');
const button = row.querySelector('.rematch-undo-btn');
button.click();
await vi.waitFor(() => expect(button.disabled).toBe(true));
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/lora/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipe_id: 'r1', lora_index: 2 }),
});
expect(row.classList.contains('undone')).toBe(true);
expect(button.textContent).toBe('Undone');
});
it('undo posts to the checkpoint restore endpoint with recipe_id only', async () => {
const showRematchSummary = await getShowRematchSummary();
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_CHECKPOINT] });
const button = document.querySelector('.rematch-undo-btn');
button.click();
await vi.waitFor(() => expect(button.disabled).toBe(true));
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/checkpoint/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipe_id: 'r2' }),
});
});
it('keeps the row actionable and toasts when undo fails', async () => {
const showRematchSummary = await getShowRematchSummary();
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: false, error: 'no snapshot' }) });
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_LORA] });
const row = document.querySelector('tr[data-l4-index="0"]');
const button = row.querySelector('.rematch-undo-btn');
button.click();
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled());
expect(button.disabled).toBe(false);
expect(row.classList.contains('undone')).toBe(false);
expect(showToastMock).toHaveBeenCalledWith(
'modals.rematchResults.undoFailed',
{ message: 'no snapshot' },
'error'
);
});
it('copy report includes scope, counts and the L4 list with undo status', async () => {
const showRematchSummary = await getShowRematchSummary();
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
const writeText = vi.fn().mockResolvedValue(undefined);
navigator.clipboard = { writeText };
showRematchSummary({
scope: 'bulk',
total: 2,
matchedRecipes: 1,
matchedEntries: 2,
unresolvedEntries: 1,
unresolvedRecipes: 1,
skipped: 0,
errors: 0,
l4Matches: [L4_LORA, L4_CHECKPOINT],
});
// Undo the first row before copying so the report carries its status.
const undoButton = document.querySelector('tr[data-l4-index="0"] .rematch-undo-btn');
undoButton.click();
await vi.waitFor(() => expect(undoButton.disabled).toBe(true));
document.querySelector('[data-action="copy-report"]').click();
await vi.waitFor(() => expect(writeText).toHaveBeenCalled());
const report = writeText.mock.calls[0][0];
expect(report).toContain('Scope: Selected recipes');
expect(report).toContain('Total recipes: 2');
expect(report).toContain('Matched entries: 2');
expect(report).toContain('Needs review (filename matches): 2');
expect(report).toContain('Unresolved entries: 1 (in 1 recipes)');
expect(report).toContain('[r1] old.safetensors -> new.safetensors [undone]');
expect(report).toContain('[r2] cp-old -> cp-new.safetensors');
// The success toast fires in the writeText .then() microtask.
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledWith('toast.api.copiedToClipboard', {}, 'success'));
});
it('close removes the modal from the DOM', async () => {
const showRematchSummary = await getShowRematchSummary();
showRematchSummary({ scope: 'single', total: 1, matchedEntries: 1 });
expect(document.getElementById('rematchSummaryModal')).not.toBeNull();
document.querySelector('[data-action="close-modal"].cancel-btn').click();
expect(document.getElementById('rematchSummaryModal')).toBeNull();
});
it('escapes HTML in L4 row fields', async () => {
const showRematchSummary = await getShowRematchSummary();
showRematchSummary({
scope: 'bulk',
total: 1,
matchedEntries: 1,
l4Matches: [{ recipe_id: 'r<x>', type: 'lora', entry: '<img src=x>', file_name: 'f.safetensors', lora_index: 0 }],
});
const modal = document.getElementById('rematchSummaryModal');
expect(modal.querySelector('.failure-table img')).toBeNull();
expect(modal.querySelector('.failure-table tbody tr').textContent).toContain('<img src=x>');
});
});
@@ -253,37 +253,4 @@ describe('AutoComplete active-filters flag', () => {
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
});
it('broadcasts a setting-toggled window event when /activefilters is accepted', async () => {
const events = [];
const listener = (event) => events.push(event.detail);
window.addEventListener('lora-manager:setting-toggled', listener);
try {
const input = document.createElement('textarea');
input.value = '/activefilters';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/activefilters');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
// The command token is cleared after acceptance; simulate the caret
// helper seeing the cleared input so the synthetic input event does
// not re-trigger command parsing (same pattern as behavior tests).
caretHelperInstance.getBeforeCursor.mockReturnValue('');
await Promise.resolve();
await Promise.resolve();
expect(events).toContainEqual({
settingId: 'loramanager.lora_active_filters_autocomplete',
value: true,
});
} finally {
window.removeEventListener('lora-manager:setting-toggled', listener);
}
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from 'vitest';
const {
API_MODULE,
APP_MODULE,
AUTOCOMPLETE_MODULE,
} = vi.hoisted(() => ({
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
}));
vi.mock(API_MODULE, () => ({
api: {
fetchApi: vi.fn(),
},
}));
vi.mock(APP_MODULE, () => ({
app: {
canvas: {
ds: { scale: 1 },
},
extensionManager: {
setting: {
get: vi.fn(),
set: vi.fn(),
},
},
registerExtension: vi.fn(),
},
}));
describe('formatAutocompleteTextOnBlur', () => {
it('preserves repeated spaces inside LoRA names', async () => {
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
expect(formatAutocompleteTextOnBlur('<lora:test - 0021:1.00>')).toBe(
'<lora:test - 0021:1.00>'
);
});
it('preserves repeated spaces across multiple LoRA entries', async () => {
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
expect(
formatAutocompleteTextOnBlur('<lora:test - 0021:1.00>,<lora:a b:0.50>')
).toBe('<lora:test - 0021:1.00>, <lora:a b:0.50>');
});
it('still normalizes whitespace outside LoRA tags', async () => {
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
expect(formatAutocompleteTextOnBlur('masterpiece, best quality')).toBe(
'masterpiece, best quality'
);
});
});
@@ -143,6 +143,16 @@ async function flushAsyncTasks() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
// The real RematchModalManager runs against the mocked modalManager; the
// global rematch menu action now opens the options dialog first and only
// starts once confirmOptions() is invoked (the user clicking Rematch).
async function getRematchModalManager() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
return rematchModalManager;
}
function createDeferred() {
let resolve;
let reject;
@@ -2223,7 +2233,7 @@ describe('Interaction-level regression coverage', () => {
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true });
});
it('runs global recipe rematch with polling and toasts the rematched count', async () => {
it('runs global recipe rematch with polling and opens the summary modal', async () => {
document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div>
@@ -2266,27 +2276,44 @@ describe('Interaction-level regression coverage', () => {
});
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
// The click only opens the options dialog — nothing starts yet.
expect(global.fetch).not.toHaveBeenCalled();
expect(rematchItem.classList.contains('disabled')).toBe(false);
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
expect(rematchItem.classList.contains('disabled')).toBe(true);
for (let i = 0; i < 5; i++) {
await flushAsyncTasks();
}
await runPromise;
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipes/rematch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: false }),
});
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipes/rematch-progress');
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(progressUI.showCancelButton).toHaveBeenCalledTimes(1);
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes.');
// Oracle R4-F1 pin: count comes from `rematched`, a blind `repaired` mirror renders undefined
expect(showToastMock).toHaveBeenCalledWith(
// A non-noop run opens the summary modal instead of toasting; the
// progress overlay completes without a message.
expect(progressUI.complete).toHaveBeenCalledWith();
expect(showToastMock).not.toHaveBeenCalledWith(
'globalContextMenu.rematchRecipes.success',
{ count: 2, recipes: 2, entries: 5, failures: 0 },
'success'
expect.anything(),
expect.anything()
);
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
// unresolved_entries > 0 forces the warning header
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('5');
expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('0');
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('1');
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0');
expect(window.recipesPage.refresh).toHaveBeenCalledTimes(1);
expect(rematchItem.classList.contains('disabled')).toBe(false);
expect(menu._rematchInProgress).toBe(false);
@@ -2295,7 +2322,7 @@ describe('Interaction-level regression coverage', () => {
delete stateStub.currentPageType;
});
it('uses the warning toast variant when a global rematch completes with failures', async () => {
it('opens the summary modal with a warning header when a global rematch completes with failures', async () => {
document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div>
@@ -2331,24 +2358,28 @@ describe('Interaction-level regression coverage', () => {
});
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(global.fetch).not.toHaveBeenCalled();
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
for (let i = 0; i < 5; i++) {
await flushAsyncTasks();
}
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes, 2 failed.');
expect(showToastMock).toHaveBeenCalledWith(
'globalContextMenu.rematchRecipes.successErrors',
{ count: 2, recipes: 2, entries: 5, failures: 2 },
'warning'
);
expect(progressUI.complete).toHaveBeenCalledWith();
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2');
expect(menu._rematchInProgress).toBe(false);
delete window.recipesPage;
delete stateStub.currentPageType;
});
it('toasts an error when every recipe in a global rematch failed', async () => {
it('opens the summary modal with an error header when every recipe in a global rematch failed', async () => {
document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div>
@@ -2384,24 +2415,28 @@ describe('Interaction-level regression coverage', () => {
});
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(global.fetch).not.toHaveBeenCalled();
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
for (let i = 0; i < 5; i++) {
await flushAsyncTasks();
}
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('Rematch failed for 3 of 3 recipes.');
expect(showToastMock).toHaveBeenCalledWith(
'globalContextMenu.rematchRecipes.allFailed',
{ total: 3, recipes: 0, entries: 0, failures: 3 },
'error'
);
expect(progressUI.complete).toHaveBeenCalledWith();
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
expect(summaryModal.querySelector('.summary-header').classList.contains('error')).toBe(true);
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('3');
expect(menu._rematchInProgress).toBe(false);
delete window.recipesPage;
delete stateStub.currentPageType;
});
it('toasts an info message when a global rematch found no local matches', async () => {
it('opens the summary modal listing unresolved entries when a global rematch found no local matches', async () => {
document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div>
@@ -2437,24 +2472,28 @@ describe('Interaction-level regression coverage', () => {
});
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(global.fetch).not.toHaveBeenCalled();
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
for (let i = 0; i < 5; i++) {
await flushAsyncTasks();
}
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('No local match found for 2 entries in 1 recipes.');
expect(showToastMock).toHaveBeenCalledWith(
'globalContextMenu.rematchRecipes.noMatch',
{ entries: 2, recipes: 1, total: 3, failures: 0 },
'info'
);
expect(progressUI.complete).toHaveBeenCalledWith();
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2');
expect(menu._rematchInProgress).toBe(false);
delete window.recipesPage;
delete stateStub.currentPageType;
});
it('toasts the rematched count when a global rematch is cancelled', async () => {
it('opens the summary modal marked as cancelled when a global rematch is cancelled', async () => {
document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div>
@@ -2489,17 +2528,22 @@ describe('Interaction-level regression coverage', () => {
});
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(global.fetch).not.toHaveBeenCalled();
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
for (let i = 0; i < 5; i++) {
await flushAsyncTasks();
}
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).');
expect(showToastMock).toHaveBeenCalledWith(
'globalContextMenu.rematchRecipes.cancelled',
{ count: 1, recipes: 1, entries: 2 },
'info'
);
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
expect(summaryModal.querySelector('.rematch-cancelled-note')).not.toBeNull();
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('2');
expect(menu._rematchInProgress).toBe(false);
delete stateStub.currentPageType;
@@ -54,7 +54,6 @@ const setSettingValueMock = vi.fn();
vi.mock(SETTINGS_MODULE, () => ({
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID:
"loramanager.lora_active_filters_autocomplete",
SETTING_TOGGLED_EVENT_NAME: "lora-manager:setting-toggled",
getLoraActiveFiltersAutocompletePreference: getActiveFiltersPreferenceMock,
setLoraManagerSettingValue: setSettingValueMock,
}));
@@ -0,0 +1,181 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const showSimpleLoadingMock = vi.fn();
const hideLoadingMock = vi.fn();
const resetAndReloadMock = vi.fn();
const probeExtensionMock = vi.fn();
const delegateReimportMock = vi.fn();
const stateStub = {
virtualScroller: { items: [] },
loadingManager: {
showSimpleLoading: showSimpleLoadingMock,
hide: hideLoadingMock,
},
};
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
copyToClipboard: vi.fn(),
sendLoraToWorkflow: vi.fn(),
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
setSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
updateRecipeMetadata: vi.fn(),
resetAndReload: resetAndReloadMock,
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: stateStub,
}));
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
moveManager: { showMoveModal: vi.fn() },
}));
vi.mock('../../../static/js/components/ContextMenu/ModelContextMenuMixin.js', () => ({
ModelContextMenuMixin: {
handleCommonMenuActions: vi.fn(() => false),
initNSFWSelector: vi.fn(),
},
}));
// Keep the real getCivitaiImageInfo (gating logic under test); mock only the
// extension communication.
vi.mock('../../../static/js/utils/extensionReimportBridge.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
probeExtension: probeExtensionMock,
delegateReimport: delegateReimportMock,
};
});
describe('RecipeContextMenu.reimportRecipe extension delegation', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = `
<div id="recipeContextMenu" class="context-menu" style="display: none;">
<div class="context-menu-item" data-action="reimport"></div>
</div>
`;
stateStub.virtualScroller.items = [
{
id: 'recipe-1',
file_path: '/recipes/recipe-1.webp',
title: 'Civitai Recipe',
source_path: 'https://civitai.com/images/12345',
},
{
id: 'recipe-2',
file_path: '/recipes/recipe-2.webp',
title: 'Local Recipe',
source_path: '/data/imports/local.png',
},
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, recipe_id: 'new-id', loras_count: 2 }),
});
});
afterEach(() => {
delete global.fetch;
});
async function createMenu() {
const { RecipeContextMenu } = await import(
'../../../static/js/components/ContextMenu/RecipeContextMenu.js'
);
return new RecipeContextMenu();
}
it('delegates to the extension for a CivitAI image source when licensed', async () => {
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
delegateReimportMock.mockResolvedValue({ completed: 1, failed: 0 });
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(delegateReimportMock).toHaveBeenCalledWith([{
recipeId: 'recipe-1',
imageId: 12345,
imageUrl: 'https://civitai.com/images/12345',
title: 'Civitai Recipe',
}]);
expect(global.fetch).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
expect(resetAndReloadMock).toHaveBeenCalledWith(false, { preserveScroll: false });
});
it('shows the failure toast when the extension reports failures', async () => {
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
delegateReimportMock.mockResolvedValue({ completed: 0, failed: 1 });
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(showToastMock).toHaveBeenCalledWith(
'recipes.contextMenu.reimport.failed',
{ message: 'Extension re-import failed' },
'error'
);
expect(global.fetch).not.toHaveBeenCalled();
expect(resetAndReloadMock).toHaveBeenCalledWith(false, { preserveScroll: false });
});
it('uses the native path for non-CivitAI sources without probing', async () => {
const menu = await createMenu();
await menu.reimportRecipe('recipe-2');
expect(probeExtensionMock).not.toHaveBeenCalled();
expect(delegateReimportMock).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-2/reimport', {
method: 'POST',
});
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
});
it('uses the native path when the extension is absent (probe timeout)', async () => {
probeExtensionMock.mockResolvedValue(null);
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(delegateReimportMock).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
method: 'POST',
});
});
it('uses the native path when the license is invalid', async () => {
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: false });
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(delegateReimportMock).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
method: 'POST',
});
});
it('falls back to the native path when delegation fails', async () => {
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
delegateReimportMock.mockRejectedValue(new Error('Extension re-import timed out'));
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
method: 'POST',
});
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
});
});
@@ -44,6 +44,22 @@ const flushAsyncTasks = async (rounds = 5) => {
}
};
// The single-recipe rematch now opens the options dialog first and only
// starts once confirmOptions() is invoked (the user clicking Rematch).
async function confirmRematchOptions() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
return rematchModalManager.confirmOptions();
}
async function cancelRematchOptions() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
rematchModalManager.cancelOptions();
}
describe('RecipeContextMenu.rematchRecipe', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -69,8 +85,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
}
// Oracle R4-F1 pin: branches on `result.rematched > 0` — a blind `repaired`
// mirror would fire the skipped toast here.
it('posts to the per-recipe rematch endpoint and toasts the rematched count', async () => {
// mirror would render 0 matched entries in the summary modal here.
it('posts to the per-recipe rematch endpoint and opens the summary modal', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
@@ -91,19 +107,33 @@ describe('RecipeContextMenu.rematchRecipe', () => {
await flushAsyncTasks();
// The click only opened the options dialog — nothing started yet.
expect(global.fetch).not.toHaveBeenCalled();
await confirmRematchOptions();
await flushAsyncTasks();
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipe/recipe-1/rematch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: false }),
});
expect(showToastMock).toHaveBeenCalledWith(
// Non-noop runs open the summary modal instead of toasting.
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchComplete',
{ rematched: 2, skipped: 0, total: 1, entries: 2, recipes: 1, failures: 0 },
'success'
expect.anything(),
expect.anything()
);
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchSkipped',
expect.anything(),
expect.anything()
);
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
expect(summaryModal.querySelector('.summary-header').classList.contains('success')).toBe(true);
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('2');
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0');
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipe/recipe-1');
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/recipe-1.webp', {
id: 'recipe-1',
@@ -111,7 +141,7 @@ describe('RecipeContextMenu.rematchRecipe', () => {
});
});
it('toasts an info message when the entries had no local match', async () => {
it('opens the summary modal when the entries had no local match', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
@@ -126,12 +156,14 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchUnmatched',
{ entries: 2, recipes: 1, total: 1 },
'info'
);
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('0');
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2');
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchSkipped',
expect.anything(),
@@ -155,6 +187,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchSkipped',
@@ -186,6 +220,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed',
@@ -207,6 +243,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed',
@@ -214,4 +252,91 @@ describe('RecipeContextMenu.rematchRecipe', () => {
'error'
);
});
it('sends relaxed: true when the relaxed checkbox is checked', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
document.body.insertAdjacentHTML(
'beforeend',
'<input type="checkbox" id="rematchOptionsRelaxed">'
);
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, rematched: 0, skipped: 1 }),
});
document
.querySelector('[data-action="rematch"]')
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
// The dialog resets the checkbox to unchecked on open; the user opts in.
document.getElementById('rematchOptionsRelaxed').checked = true;
await confirmRematchOptions();
await flushAsyncTasks();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/rematch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: true }),
});
});
it('starts nothing when the options dialog is cancelled', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
document
.querySelector('[data-action="rematch"]')
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
await cancelRematchOptions();
await flushAsyncTasks();
expect(global.fetch).not.toHaveBeenCalled();
});
it('lists L4 filename matches in the summary modal with undo buttons', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
const l4Matches = [
{ recipe_id: 'recipe-1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 },
];
global.fetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, rematched: 1, matched_entries: 1, l4_matches: l4Matches }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'recipe-1', title: 'Updated Recipe' }),
});
document
.querySelector('[data-action="rematch"]')
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
// L4 matches to review force the warning header
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('1');
const rows = summaryModal.querySelectorAll('tr[data-l4-index]');
expect(rows).toHaveLength(1);
expect(rows[0].textContent).toContain('old.safetensors');
expect(rows[0].textContent).toContain('new.safetensors');
expect(rows[0].querySelector('.rematch-undo-btn[data-action="undo-match"][data-index="0"]')).not.toBeNull();
});
});
@@ -51,6 +51,10 @@ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
stripLoraTags: vi.fn((text) => text),
sendPromptToWorkflow: vi.fn(),
sendGenParamsToWorkflow: vi.fn(),
// Keep the real predicate: the download-failure tests assert on its
// unresolvable-error classification.
isUnresolvableDownloadError: (message) =>
!!message && /(not found|no longer available|deleted|removed|404|410|gone)/.test(String(message).toLowerCase()),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
@@ -153,6 +157,16 @@ const hashInvalidLora = {
hashInvalid: true,
};
// Mirrors the shape served for page-imported recipes whose CivitAI version
// exposes no sha256: an exact modelVersionId but no modelId and no hash.
const versionOnlyLora = {
name: 'version-lora',
modelName: 'Version Only LoRA',
inLibrary: false,
modelVersionId: 3221586,
modelVersionName: 'V1 KREA-2',
};
const recipeWithResources = {
id: 'recipe-resources',
file_path: '/recipes/resources.json',
@@ -171,6 +185,7 @@ const recipeWithResources = {
hashInvalidLora,
{ name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false },
hashOnlyLora,
versionOnlyLora,
],
};
@@ -281,6 +296,59 @@ describe('RecipeModal resource item interactions', () => {
);
});
it('renders a download action alongside reconnect for a version-only LoRA', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
const item = document.querySelector('[data-lora-index="6"]');
expect(item).not.toBeNull();
expect(item.classList.contains('missing-locally')).toBe(true);
// Missing from the local library (badge) but still downloadable by its
// exact CivitAI version id, so the row offers Download as the primary
// action; Reconnect stays available for entries the user already has
// locally under a different hash.
expect(item.querySelector('.missing-badge')).not.toBeNull();
expect(item.querySelector('.lora-download')).not.toBeNull();
expect(item.querySelector('.lora-reconnect')).not.toBeNull();
});
it('downloads a version-only LoRA by resolving the model id from the version endpoint', async () => {
const recipeModal = await createRecipeModal();
const requests = [];
// Isolated copy keeps mutations out of the shared fixture.
const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources));
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
global.fetch = vi.fn(async (url) => {
requests.push(String(url));
if (String(url).includes('/civitai/model/version/3221586')) {
return {
ok: true,
json: async () => ({ id: 3221586, modelId: 56789, name: 'V1 KREA-2' }),
};
}
return { ok: true, json: async () => ({}) };
});
recipeModal.showRecipeDetails(isolatedRecipe);
await flushWiring();
const item = document.querySelector('[data-lora-index="6"]');
item.querySelector('.lora-download').click();
await vi.waitFor(() => {
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1);
});
expect(
requests.some(u => u.includes('/civitai/model/version/3221586'))
).toBe(true);
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledWith(
'loras',
56789,
3221586,
expect.objectContaining({ source: 'recipe-modal' })
);
});
it('does not navigate when a missing LoRA row is clicked', async () => {
const recipeModal = await createRecipeModal();
const navigateSpy = vi
@@ -526,10 +594,12 @@ describe('RecipeModal resource item interactions', () => {
await new Promise(resolve => setTimeout(resolve, 50));
expect(requests.some(r => r.url.includes('mark-hash-invalid'))).toBe(false);
// The entry keeps the download action and never flips to reconnect
// The entry keeps the download action and never flips to hash-invalid
// (reconnect is always present for missing entries now; the signal here
// is that the download action survives and no invalid badge appears)
const item = document.querySelector('[data-lora-index="1"]');
expect(item.querySelector('.lora-download')).not.toBeNull();
expect(item.querySelector('.lora-reconnect')).toBeNull();
expect(item.querySelector('.invalid-hash-badge')).toBeNull();
});
it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => {
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderTemplate } from '../utils/domFixtures.js';
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
setupAutoNewlineOnPaste: vi.fn(),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: (key, params = {}, fallback = null) => fallback ?? key,
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
WS_ENDPOINTS: {},
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getStorageItem: vi.fn(() => true),
setStorageItem: vi.fn(),
}));
describe('BatchImportManager directory browser (#1106)', () => {
let batchImportManager;
let fetchMock;
let showToast;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
document.body.innerHTML = '';
renderTemplate('components/batch_import_modal.html');
fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ success: true }),
}));
vi.stubGlobal('fetch', fetchMock);
const uiHelpers = await import('../../../static/js/utils/uiHelpers.js');
showToast = uiHelpers.showToast;
const batchModule = await import('../../../static/js/managers/BatchImportManager.js');
batchImportManager = new batchModule.BatchImportManager();
});
afterEach(() => {
vi.unstubAllGlobals();
});
function lastRequestBody() {
return JSON.parse(fetchMock.mock.calls.at(-1)[1].body);
}
it('opens the browser with an empty path so the server picks the default', async () => {
// The old POSIX-only "/" initial path fails the access check on Windows.
document.getElementById('batchDirectoryInput').value = '';
batchImportManager.toggleDirectoryBrowser();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(lastRequestBody().path).toBe('');
expect(document.getElementById('batchDirectoryBrowser').style.display).toBe('block');
});
it('navigates to the parent using the server-provided path (Windows-safe)', async () => {
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({
success: true,
current_path: 'C:\\Users\\miao\\Pictures',
parent_path: 'C:\\Users\\miao',
directories: [],
image_files: [],
image_count: 0,
directory_count: 0,
}),
}));
await batchImportManager.loadDirectory('C:\\Users\\miao\\Pictures');
fetchMock.mockClear();
batchImportManager.navigateToParentDirectory();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(lastRequestBody().path).toBe('C:\\Users\\miao');
});
it('refuses to select a virtual level that has no current path (drive list)', async () => {
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({
success: true,
current_path: '',
parent_path: null,
directories: [{ name: 'C:\\', path: 'C:\\', is_parent: false }],
image_files: [],
image_count: 0,
directory_count: 1,
}),
}));
await batchImportManager.loadDirectory('__drives__');
batchImportManager.selectCurrentDirectory();
expect(showToast).toHaveBeenCalledWith('toast.recipes.batchImportNoDirectory', {}, 'error');
expect(document.getElementById('batchDirectoryInput').value).toBe('');
});
});
@@ -73,12 +73,29 @@ vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({
getNsfwLevelSelector: vi.fn(),
}));
// The real RematchModalManager runs against the mocked modalManager; confirm
// is invoked explicitly, mirroring the user clicking Rematch in the dialog.
async function confirmRematchOptions() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
return rematchModalManager.confirmOptions();
}
async function cancelRematchOptions() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
rematchModalManager.cancelOptions();
}
describe('BulkManager.rematchSelectedRecipes', () => {
beforeEach(() => {
vi.clearAllMocks();
stateStub.currentPageType = 'recipes';
stateStub.bulkMode = false;
stateStub.selectedModels.clear();
document.body.innerHTML = '';
});
async function createBulkManager() {
@@ -91,9 +108,9 @@ describe('BulkManager.rematchSelectedRecipes', () => {
expect(bulk.actionConfig.recipes.rematchMetadata).toBe(true);
});
// Oracle R4-F1 pin: the complete toast must branch on `rematched` — a blind
// `repaired` mirror would fire the skipped toast with count 0 here.
it('toasts the rematched count when the bulk rematch succeeds', async () => {
// Oracle R4-F1 pin: the summary modal must branch on `matched_entries` — a
// blind `repaired` mirror would render 0 matched entries here.
it('opens the summary modal when the bulk rematch succeeds', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
stateStub.selectedModels.add('/recipes/b.webp');
@@ -114,29 +131,41 @@ describe('BulkManager.rematchSelectedRecipes', () => {
});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(rematchBulkModelsMock).toHaveBeenCalledWith([
'/recipes/a.webp',
'/recipes/b.webp',
'/recipes/c.webp',
]);
expect(showToastMock).toHaveBeenCalledWith(
expect(rematchBulkModelsMock).toHaveBeenCalledWith(
[
'/recipes/a.webp',
'/recipes/b.webp',
'/recipes/c.webp',
],
{ relaxed: false }
);
// Non-noop runs open the summary modal instead of toasting.
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchComplete',
{ rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 },
'success'
expect.anything(),
expect.anything()
);
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchSkipped',
expect.anything(),
expect.anything()
);
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
// unresolved_entries > 0 forces the warning header
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('4');
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('1');
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0');
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/a.webp', rematchedRecipe);
expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalled();
expect(loadingManagerStub.hide).toHaveBeenCalled();
expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled();
});
it('uses the errors toast variant when the bulk rematch has failures', async () => {
it('opens the summary modal with a warning header when the bulk rematch has failures', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
stateStub.selectedModels.add('/recipes/b.webp');
@@ -155,15 +184,16 @@ describe('BulkManager.rematchSelectedRecipes', () => {
});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchCompleteErrors',
{ rematched: 3, skipped: 0, total: 2, entries: 3, recipes: 1, failures: 2 },
'warning'
);
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('3');
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2');
});
it('toasts an error when every selected recipe failed to rematch', async () => {
it('opens the summary modal with an error header when every selected recipe failed to rematch', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
stateStub.selectedModels.add('/recipes/b.webp');
@@ -182,12 +212,12 @@ describe('BulkManager.rematchSelectedRecipes', () => {
});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchAllFailed',
{ total: 2, failures: 2 },
'error'
);
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
expect(summaryModal.querySelector('.summary-header').classList.contains('error')).toBe(true);
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2');
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchSkipped',
expect.anything(),
@@ -195,7 +225,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
);
});
it('toasts an info message when entries had no local match', async () => {
it('opens the summary modal when entries had no local match', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
stateStub.selectedModels.add('/recipes/b.webp');
@@ -215,12 +245,13 @@ describe('BulkManager.rematchSelectedRecipes', () => {
});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchUnmatched',
{ entries: 2, recipes: 1, total: 3 },
'info'
);
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('0');
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2');
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchSkipped',
expect.anything(),
@@ -243,6 +274,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchSkipped',
@@ -268,6 +300,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed',
@@ -284,6 +317,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
rematchBulkModelsMock.mockRejectedValue(new Error('network down'));
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed',
@@ -319,4 +353,101 @@ describe('BulkManager.rematchSelectedRecipes', () => {
);
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
});
it('does not start the rematch until the options dialog is confirmed', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
await bulk.rematchSelectedRecipes();
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
rematchBulkModelsMock.mockResolvedValue({
success: true,
total: 1,
rematched: 1,
skipped: 0,
errors: 0,
matched_recipes: 1,
matched_entries: 1,
recipes: [],
});
await confirmRematchOptions();
expect(rematchBulkModelsMock).toHaveBeenCalledWith(['/recipes/a.webp'], { relaxed: false });
});
it('sends relaxed: true when the relaxed checkbox is checked', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
document.body.innerHTML = '<input type="checkbox" id="rematchOptionsRelaxed">';
rematchBulkModelsMock.mockResolvedValue({
success: true,
total: 1,
rematched: 0,
skipped: 1,
errors: 0,
recipes: [],
});
await bulk.rematchSelectedRecipes();
// The dialog resets the checkbox to unchecked on open; the user opts in.
document.getElementById('rematchOptionsRelaxed').checked = true;
await confirmRematchOptions();
expect(rematchBulkModelsMock).toHaveBeenCalledWith(['/recipes/a.webp'], { relaxed: true });
document.body.innerHTML = '';
});
it('starts nothing when the options dialog is cancelled', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
await bulk.rematchSelectedRecipes();
await cancelRematchOptions();
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchComplete',
expect.anything(),
expect.anything()
);
});
it('lists L4 filename matches in the summary modal with undo buttons', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
const l4Matches = [
{ recipe_id: 'a', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 },
];
rematchBulkModelsMock.mockResolvedValue({
success: true,
total: 1,
rematched: 1,
skipped: 0,
errors: 0,
matched_recipes: 1,
matched_entries: 1,
recipes: [],
l4_matches: l4Matches,
});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
// L4 matches to review force the warning header
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('1');
const rows = summaryModal.querySelectorAll('tr[data-l4-index]');
expect(rows).toHaveLength(1);
expect(rows[0].textContent).toContain('old.safetensors');
expect(rows[0].textContent).toContain('new.safetensors');
expect(rows[0].querySelector('.rematch-undo-btn[data-action="undo-match"][data-index="0"]')).not.toBeNull();
});
});
@@ -0,0 +1,192 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const MODULE = '../../../static/js/managers/BulkMissingLoraDownloadManager.js';
const showToastMock = vi.fn();
const updateProgressMock = vi.fn();
const updateSingleItemMock = vi.fn();
const mockApiClient = {
downloadModel: vi.fn(),
cancelDownload: vi.fn(),
fetchModelRoots: vi.fn(() => Promise.resolve({ roots: ['/models/loras'] })),
};
const loadingManagerStub = {
showDownloadProgress: vi.fn(() => updateProgressMock),
setStatus: vi.fn(),
showCancelButton: vi.fn(),
hide: vi.fn(),
};
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
// Keep the real predicate: these tests assert on its classification.
isUnresolvableDownloadError: (message) =>
!!message && /(not found|no longer available|deleted|removed|404|410|gone)/.test(String(message).toLowerCase()),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: vi.fn(() => mockApiClient),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
extractRecipeId: (filePath) => {
if (!filePath) return null;
const basename = filePath.split('/').pop().split('\\').pop();
const dotIndex = basename.lastIndexOf('.');
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
},
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: {
loadingManager: loadingManagerStub,
virtualScroller: { updateSingleItem: updateSingleItemMock },
global: { settings: {} },
},
}));
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
}));
/** Mirrors the FakeWebSocket pattern from downloadManager.batchSummary.test.js. */
class FakeWebSocket {
static instances = [];
constructor(url) {
this.url = url;
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.close = vi.fn();
FakeWebSocket.instances.push(this);
queueMicrotask(() => {
if (this.onopen) this.onopen();
});
}
}
const makeRecipe = (filePath, loras) => ({ file_path: filePath, loras });
describe('BulkMissingLoraDownloadManager unresolvable-failure write-back', () => {
let manager;
let fetchMock;
let requests;
beforeEach(async () => {
FakeWebSocket.instances = [];
vi.clearAllMocks();
loadingManagerStub.showDownloadProgress.mockReturnValue(updateProgressMock);
requests = [];
fetchMock = vi.fn((url, options) => {
requests.push({ url, options });
if (url === '/api/lm/recipe/lora/mark-hash-invalid') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
// Recipe detail refresh after the download loop
return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'refreshed' }) });
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', FakeWebSocket);
vi.resetModules();
({ bulkMissingLoraDownloadManager: manager } = await import(MODULE));
manager.pendingLoras = [];
manager.pendingRecipes = [];
manager.pendingMissingByRecipe = null;
});
afterEach(() => {
vi.unstubAllGlobals();
});
const primePending = (recipes) => {
const stats = manager.collectMissingLoras(recipes);
manager.pendingRecipes = recipes;
manager.pendingMissingByRecipe = stats.missingLorasByRecipe;
return stats.uniqueLoras;
};
it('marks every recipe occurrence hash-invalid when the failure is unresolvable', async () => {
const entryA = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const entryB = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const recipe1 = makeRecipe('/recipes/r1.json', [entryA]);
const recipe2 = makeRecipe('/recipes/r2.json', [{ hash: 'x', file_name: 'keep.safetensors', inLibrary: true }, entryB]);
const uniqueLoras = primePending([recipe1, recipe2]);
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'Model not found' });
await manager.executeDownload(uniqueLoras);
const markCalls = requests.filter(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid');
expect(markCalls).toHaveLength(2);
const payloads = markCalls.map(r => JSON.parse(r.options.body));
expect(payloads).toContainEqual({ recipe_id: 'r1', lora_index: 0 });
expect(payloads).toContainEqual({ recipe_id: 'r2', lora_index: 1 });
expect(entryA.hashInvalid).toBe(true);
expect(entryB.hashInvalid).toBe(true);
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.unresolvableMarkedForReconnect',
{ count: 2 },
'info',
expect.any(String),
);
});
it('leaves entries untouched when the failure is transient', async () => {
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const recipe = makeRecipe('/recipes/r1.json', [entry]);
const uniqueLoras = primePending([recipe]);
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'Connection timed out' });
await manager.executeDownload(uniqueLoras);
expect(requests.some(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid')).toBe(false);
expect(entry.hashInvalid).toBeUndefined();
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.unresolvableMarkedForReconnect',
expect.anything(),
expect.anything(),
expect.anything(),
);
});
it('marks hash-invalid when the download request itself throws an unresolvable error', async () => {
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const recipe = makeRecipe('/recipes/r1.json', [entry]);
const uniqueLoras = primePending([recipe]);
mockApiClient.downloadModel.mockRejectedValue(new Error('410 Gone'));
await manager.executeDownload(uniqueLoras);
const markCalls = requests.filter(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid');
expect(markCalls).toHaveLength(1);
expect(entry.hashInvalid).toBe(true);
});
it('does not mark entries whose download succeeds', async () => {
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const recipe = makeRecipe('/recipes/r1.json', [entry]);
const uniqueLoras = primePending([recipe]);
mockApiClient.downloadModel.mockResolvedValue({ success: true });
await manager.executeDownload(uniqueLoras);
expect(requests.some(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid')).toBe(false);
});
});
+73 -1
View File
@@ -17,7 +17,8 @@ vi.mock('../../../static/js/state/index.js', () => ({
}
}
}
}
},
getCurrentPageState: vi.fn(() => ({ activeFolder: null, searchOptions: {} }))
}));
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
@@ -162,4 +163,75 @@ describe('MoveManager', () => {
true
);
});
it('should propagate the recalculated sub_type from the move response to the card', async () => {
// Setup state: moving a checkpoint into the unet root
moveManager.useDefaultPath = false;
moveManager.bulkFilePaths = null;
moveManager.currentFilePath = '/models/checkpoints/model.safetensors';
moveManager.modelRoots = ['/models/checkpoints', '/models/unet'];
document.getElementById('moveModelRoot').innerHTML = '<option value="/models/unet">/models/unet</option>';
document.getElementById('moveModelRoot').value = '/models/unet';
moveManager.folderTreeManager.selectedPath = '';
const updateSingleItem = vi.fn();
state.virtualScroller = {
updateSingleItem,
removeMultipleItemsByFilePath: vi.fn()
};
mockApiClient.moveSingleModel = vi.fn().mockResolvedValue({
success: true,
original_file_path: '/models/checkpoints/model.safetensors',
new_file_path: '/models/unet/model.safetensors',
cache_entry: { sub_type: 'diffusion_model' }
});
try {
await moveManager.moveModel();
expect(updateSingleItem).toHaveBeenCalledWith(
'/models/checkpoints/model.safetensors',
expect.objectContaining({
file_path: '/models/unet/model.safetensors',
sub_type: 'diffusion_model'
})
);
} finally {
delete state.virtualScroller;
}
});
it('should omit sub_type from the card update when the response has no cache entry', async () => {
moveManager.useDefaultPath = false;
moveManager.bulkFilePaths = null;
moveManager.currentFilePath = '/models/loras/a.safetensors';
moveManager.modelRoots = ['/models/loras'];
document.getElementById('moveModelRoot').innerHTML = '<option value="/models/loras">/models/loras</option>';
document.getElementById('moveModelRoot').value = '/models/loras';
moveManager.folderTreeManager.selectedPath = '';
const updateSingleItem = vi.fn();
state.virtualScroller = {
updateSingleItem,
removeMultipleItemsByFilePath: vi.fn()
};
mockApiClient.moveSingleModel = vi.fn().mockResolvedValue({
success: true,
original_file_path: '/models/loras/a.safetensors',
new_file_path: '/models/loras/b/a.safetensors'
});
try {
await moveManager.moveModel();
expect(updateSingleItem).toHaveBeenCalledWith(
'/models/loras/a.safetensors',
expect.not.objectContaining({ sub_type: expect.anything() })
);
} finally {
delete state.virtualScroller;
}
});
});
@@ -0,0 +1,118 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
const modalManagerMock = {
showModal: vi.fn(),
closeModal: vi.fn(),
};
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: modalManagerMock,
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: translateMock,
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
async function getManager() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
return rematchModalManager;
}
describe('RematchModalManager options dialog', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = `
<p id="rematchOptionsMessage"></p>
<input type="checkbox" id="rematchOptionsRelaxed">
`;
});
afterEach(() => {
document.body.innerHTML = '';
});
it('does not invoke the callback until confirmOptions is called', async () => {
const manager = await getManager();
const onConfirm = vi.fn();
manager.showOptionsModal({ recipeCount: 3, onConfirm });
expect(modalManagerMock.showModal).toHaveBeenCalledWith('rematchOptionsModal');
expect(onConfirm).not.toHaveBeenCalled();
// Bulk message mentions the selection size.
expect(document.getElementById('rematchOptionsMessage').textContent).toContain('3');
// The checkbox always starts unchecked.
expect(document.getElementById('rematchOptionsRelaxed').checked).toBe(false);
manager.confirmOptions();
expect(onConfirm).toHaveBeenCalledWith({ relaxed: false });
expect(modalManagerMock.closeModal).toHaveBeenCalledWith('rematchOptionsModal');
});
it('uses the generic message when no recipe count is given', async () => {
const manager = await getManager();
manager.showOptionsModal({ onConfirm: vi.fn() });
expect(translateMock).toHaveBeenCalledWith(
'modals.rematchOptions.messageGlobal',
{},
'All recipes will be scanned against your local model library.'
);
});
it('uses the single-recipe message for scope: single', async () => {
const manager = await getManager();
manager.showOptionsModal({ scope: 'single', onConfirm: vi.fn() });
expect(translateMock).toHaveBeenCalledWith(
'modals.rematchOptions.messageSingle',
{},
'This recipe will be scanned against your local model library.'
);
});
it('passes relaxed: true when the checkbox is checked', async () => {
const manager = await getManager();
const onConfirm = vi.fn();
manager.showOptionsModal({ onConfirm });
document.getElementById('rematchOptionsRelaxed').checked = true;
manager.confirmOptions();
expect(onConfirm).toHaveBeenCalledWith({ relaxed: true });
});
it('resets the checkbox to unchecked each time the dialog opens', async () => {
const manager = await getManager();
const checkbox = document.getElementById('rematchOptionsRelaxed');
checkbox.checked = true;
manager.showOptionsModal({ onConfirm: vi.fn() });
expect(checkbox.checked).toBe(false);
});
it('cancelOptions runs nothing and clears the callback', async () => {
const manager = await getManager();
const onConfirm = vi.fn();
manager.showOptionsModal({ onConfirm });
manager.cancelOptions();
expect(modalManagerMock.closeModal).toHaveBeenCalledWith('rematchOptionsModal');
// A later confirm must not fire the cancelled callback.
manager.confirmOptions();
expect(onConfirm).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
DOWNLOAD_MANAGER_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
LOADING_MANAGER_MODULE,
API_FACTORY_MODULE,
STORAGE_HELPERS_MODULE,
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
SUMMARY_MODULE,
} = vi.hoisted(() => ({
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
setupAutoNewlineOnPaste: vi.fn(),
}));
vi.mock(STATE_MODULE, () => ({
state: { global: { settings: {} }, loadingManager: {} },
}));
vi.mock(LOADING_MANAGER_MODULE, () => ({
LoadingManager: vi.fn(() => ({})),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(),
resetAndReload: vi.fn(),
}));
vi.mock(STORAGE_HELPERS_MODULE, () => ({
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
setStorageItem: vi.fn(),
}));
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
FolderTreeManager: vi.fn(() => ({})),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_key, _vars, fallback) => fallback ?? ''),
}));
vi.mock(SUMMARY_MODULE, () => ({
showDownloadBatchSummary: vi.fn(),
}));
const { DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE);
describe('DownloadManager._resolveIsDiffusionModel', () => {
let manager;
let fetchMock;
beforeEach(() => {
manager = new DownloadManager();
manager.apiClient = { modelType: 'checkpoints' };
manager.selectedFile = null;
manager.selectedFiles = [];
manager.currentVersion = null;
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function mockRoutingResponse(data, ok = true) {
fetchMock.mockResolvedValue({
ok,
status: ok ? 200 : 500,
json: async () => data,
});
}
it('asks the backend and routes baseModel-only diffusion models to unet roots', async () => {
// The reported Anima case: file type is plain "Model".
manager.currentVersion = { baseModel: 'Anima', files: [{ type: 'Model' }] };
mockRoutingResponse({ success: true, is_diffusion_model: true, root_kind: 'unet' });
expect(await manager._resolveIsDiffusionModel()).toBe(true);
expect(fetchMock).toHaveBeenCalledWith('/api/lm/download/routing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_type: 'checkpoint',
base_model: 'Anima',
file_types: ['Model'],
}),
});
});
it('returns the backend decision for regular checkpoints', async () => {
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'Model' }] };
mockRoutingResponse({ success: true, is_diffusion_model: false, root_kind: 'checkpoint' });
expect(await manager._resolveIsDiffusionModel()).toBe(false);
});
it('sends only the selected file type when a file is selected', async () => {
manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'Model' }, { type: 'UNet' }] };
manager.selectedFile = { type: 'UNet' };
mockRoutingResponse({ success: true, is_diffusion_model: true, root_kind: 'unet' });
expect(await manager._resolveIsDiffusionModel()).toBe(true);
expect(JSON.parse(fetchMock.mock.calls[0][1].body).file_types).toEqual(['UNet']);
});
it('falls back to the local file-type check when the endpoint fails', async () => {
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'UNet' }] };
fetchMock.mockRejectedValue(new Error('network down'));
expect(await manager._resolveIsDiffusionModel()).toBe(true);
});
it('falls back to false when the endpoint fails and no local signal exists', async () => {
manager.currentVersion = { baseModel: 'Anima', files: [{ type: 'Model' }] };
mockRoutingResponse({}, false);
expect(await manager._resolveIsDiffusionModel()).toBe(false);
});
it('never calls the endpoint for non-checkpoint pages', async () => {
manager.apiClient = { modelType: 'loras' };
manager.currentVersion = { baseModel: 'Anima', files: [{ type: 'Model' }] };
expect(await manager._resolveIsDiffusionModel()).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it('never calls the endpoint without version metadata (e.g. Hugging Face)', async () => {
expect(await manager._resolveIsDiffusionModel()).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -40,6 +40,15 @@ describe("applyLoraValuesToText", () => {
expect(result).toBe("<lora:Expanded:1.00:1.00>");
});
it("preserves repeated spaces inside LoRA names", () => {
const original = "<lora:test - 0021:1.00>";
const result = applyLoraValuesToText(original, [
{ name: "test - 0021", strength: 0.5 }
]);
expect(result).toBe("<lora:test - 0021:0.50>");
});
});
describe("normalizeStrengthValue", () => {
@@ -74,6 +83,18 @@ describe("cleanupLoraSyntax", () => {
it("collapses whitespace and stray commas", () => {
expect(cleanupLoraSyntax(" <lora:A:1.00> , ," )).toBe("<lora:A:1.00>");
});
it("preserves repeated spaces inside LoRA names", () => {
expect(cleanupLoraSyntax("<lora:test - 0021:1.00> , ,")).toBe(
"<lora:test - 0021:1.00>"
);
});
it("still normalizes whitespace between entries", () => {
expect(
cleanupLoraSyntax(" <lora:A:1.00> <lora:test - 0021:0.50> ")
).toBe("<lora:A:1.00> <lora:test - 0021:0.50>");
});
});
describe("debounce", () => {
@@ -0,0 +1,216 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
probeExtension,
delegateReimport,
getCivitaiImageInfo,
} from '../../../static/js/utils/extensionReimportBridge.js';
const dispatchedEvents = [];
function dispatchProtocolEvent(type, payload) {
document.dispatchEvent(
new CustomEvent(type, { detail: JSON.stringify(payload) })
);
}
// Installs a fake extension that answers probes with the given result.
function installProbeResponder(result) {
const listener = () => dispatchProtocolEvent('lm:reimportProbeResult', result);
document.addEventListener('lm:reimportProbe', listener);
return () => document.removeEventListener('lm:reimportProbe', listener);
}
afterEach(() => {
dispatchedEvents.length = 0;
});
describe('probeExtension', () => {
it('resolves null when no extension answers within the timeout', async () => {
const result = await probeExtension({ timeoutMs: 20 });
expect(result).toBeNull();
});
it('resolves the probe result when the extension answers', async () => {
const uninstall = installProbeResponder({
supported: true,
licenseValid: true,
extensionVersion: '1.2.3',
});
try {
const result = await probeExtension({ timeoutMs: 1000 });
expect(result).toEqual({
supported: true,
licenseValid: true,
extensionVersion: '1.2.3',
reason: undefined,
});
} finally {
uninstall();
}
});
it('reports unsupported/unlicensed answers verbatim', async () => {
const uninstall = installProbeResponder({
supported: false,
licenseValid: false,
reason: 'license expired',
});
try {
const result = await probeExtension({ timeoutMs: 1000 });
expect(result.supported).toBe(false);
expect(result.licenseValid).toBe(false);
expect(result.reason).toBe('license expired');
} finally {
uninstall();
}
});
it('ignores malformed probe results and times out', async () => {
const listener = () => {
document.dispatchEvent(
new CustomEvent('lm:reimportProbeResult', { detail: '{broken json' })
);
};
document.addEventListener('lm:reimportProbe', listener);
try {
const result = await probeExtension({ timeoutMs: 20 });
expect(result).toBeNull();
} finally {
document.removeEventListener('lm:reimportProbe', listener);
}
});
});
describe('delegateReimport', () => {
const recipes = [
{ recipeId: 'r1', imageId: 123, imageUrl: 'https://civitai.com/images/123', title: 'One' },
{ recipeId: 'r2', imageId: 456, imageUrl: 'https://civitai.com/images/456', title: 'Two' },
];
it('rejects immediately for an empty recipe list', async () => {
await expect(delegateReimport([])).rejects.toThrow('non-empty');
});
it('rejects on timeout when the extension never answers', async () => {
await expect(
delegateReimport(recipes, { timeoutMs: 20 })
).rejects.toThrow('timed out');
});
it('dispatches the batch with a requestId and resolves on batchDone', async () => {
const progressEvents = [];
let seenRequest = null;
const listener = (event) => {
seenRequest = JSON.parse(event.detail);
const { requestId } = seenRequest;
// Progress for a DIFFERENT batch must be ignored.
dispatchProtocolEvent('lm:reimportProgress', {
requestId: 'other-batch',
current: 99,
total: 99,
recipeId: 'nope',
title: 'nope',
status: 'success',
});
dispatchProtocolEvent('lm:reimportProgress', {
requestId,
current: 1,
total: 2,
recipeId: 'r1',
title: 'One',
status: 'success',
});
dispatchProtocolEvent('lm:reimportProgress', {
requestId,
current: 2,
total: 2,
recipeId: 'r2',
title: 'Two',
status: 'failed',
message: 'boom',
});
dispatchProtocolEvent('lm:reimportBatchDone', {
requestId,
completed: 1,
failed: 1,
});
};
document.addEventListener('lm:reimportViaExtension', listener);
try {
const result = await delegateReimport(recipes, {
onProgress: (progress) => progressEvents.push(progress),
timeoutMs: 1000,
});
expect(seenRequest.recipes).toEqual(recipes);
expect(typeof seenRequest.requestId).toBe('string');
expect(seenRequest.requestId.length).toBeGreaterThan(0);
expect(result).toEqual({ completed: 1, failed: 1 });
// Only this batch's progress events reach the callback.
expect(progressEvents.map((p) => p.recipeId)).toEqual(['r1', 'r2']);
expect(progressEvents[1].status).toBe('failed');
} finally {
document.removeEventListener('lm:reimportViaExtension', listener);
}
});
it('resets the timeout on every progress heartbeat', async () => {
vi.useFakeTimers();
let requestId = null;
const listener = (event) => {
requestId = JSON.parse(event.detail).requestId;
};
document.addEventListener('lm:reimportViaExtension', listener);
try {
const promise = delegateReimport(recipes, { timeoutMs: 1000 });
// At t=900ms a progress event arrives, pushing the deadline to t=1900ms.
await vi.advanceTimersByTimeAsync(900);
dispatchProtocolEvent('lm:reimportProgress', {
requestId,
current: 1,
total: 2,
recipeId: 'r1',
title: 'One',
status: 'started',
});
// t=1800ms: past the original deadline, still alive thanks to heartbeat.
await vi.advanceTimersByTimeAsync(900);
dispatchProtocolEvent('lm:reimportBatchDone', {
requestId,
completed: 2,
failed: 0,
});
await expect(promise).resolves.toEqual({ completed: 2, failed: 0 });
} finally {
document.removeEventListener('lm:reimportViaExtension', listener);
vi.useRealTimers();
}
});
});
describe('getCivitaiImageInfo', () => {
it.each([
'https://civitai.com/images/12345',
'https://civitai.red/images/12345',
'https://civitai.green/images/12345',
'https://civitai.com/images/12345?foo=bar',
])('extracts the image id from %s', (url) => {
expect(getCivitaiImageInfo(url)).toEqual({ imageId: 12345, imageUrl: url });
});
it.each([
null,
'',
'not a url',
'ftp://civitai.com/images/12345',
'https://civitai.com/models/12345',
'https://example.com/images/12345',
'https://image.civitai.com/x/y/original=true/pic.png',
])('returns null for %s', (url) => {
expect(getCivitaiImageInfo(url)).toBeNull();
});
});

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