Compare commits

..

111 Commits

Author SHA1 Message Date
Will Miao 86aa1d8059 fix(ui): position toasts below header to avoid overlapping page controls 2026-08-20 22:08:27 +08:00
Will Miao 74254756ef fix(ui): unify modal backdrop blur across all modals 2026-08-20 21:25:05 +08:00
Will Miao 259e08e47c feat(download): expose per-file downloadedFiles in check-model-exists (#1058)
The version branch of check-model-exists now returns
downloadedFiles: [{fileId, fileName, filePath}] so clients (e.g. the
browser extension) can tell a partially downloaded version apart from a
fully downloaded one. Reuses ModelCivitaiHandler._match_downloaded_files
(D2 rule) against the local cache; unmatchable local files are reported
with fileId: None. No CivitAI API call added.
2026-08-20 20:59:19 +08:00
Will Miao 6647c45731 fix(download): include file identity in queue/history dedup (#1058)
Distinct files of the same model version queued before a backend restart
were silently collapsed by deduplicate(), which grouped rows by
(model_id, model_version_id) only. Extract the file id from file_params
via json_extract and add it to the dedup key; rows without file identity
keep the old per-version behavior (NULL matches NULL).
2026-08-20 20:58:44 +08:00
Will Miao b614a5c447 docs: remove broken star history chart (#1066) 2026-08-20 20:56:22 +08:00
Will Miao b80830913c refactor(nodes): declare loras widget as LORAS input type on lora nodes 2026-08-20 13:22:11 +08:00
Will Miao e57e11897e refactor(services): share weight-file extension set between rematch and find_matching_models 2026-08-19 21:54:22 +08:00
Will Miao 8a16034135 refactor(services): unify local model name matching with uniqueness and base-model guards (#1065)
Consolidate the duplicate name-matching logic into ModelScanner:
find_matching_models is now the single core, using each scanner's own
file_extensions for suffix stripping. get_model_info_by_name gains
require_unique/base_model kwargs while legacy route behavior is kept
byte-identical. reconnect_lora passes the recipe base model as a guard
and distinguishes ambiguous, base-model-mismatched, and missing LoRAs
in its error messages.
2026-08-19 21:02:04 +08:00
Will Miao 7fc3b7e5be docs: fix star history chart with official token-based embed (#1066) 2026-08-19 20:51:23 +08:00
Aaalice b0c7a1baae Fix recipe parsing for metadata-free local LoRAs (#1065)
* fix(recipes): resolve metadata-free local LoRAs

* fix(recipes): prioritize LoRA hashes over names
2026-08-19 19:07:17 +08:00
Will Miao 6411d83d46 fix(i18n): translate remaining untranslated UI strings 2026-08-19 18:59:55 +08:00
Will Miao 74a063b0e5 fix(i18n): complete translations for per-file download UI (#1058) 2026-08-19 18:53:31 +08:00
Will Miao 96376e5cce fix(download): hide URL step when file dialog opens from versions tab (#1058) 2026-08-19 18:35:16 +08:00
Will Miao e7c26bf722 feat(download): per-file download status and multi-file selection (#1058) 2026-08-19 17:51:31 +08:00
Will Miao cef4129fc9 fix(download): allow downloading additional files of an in-library model version (#1058) 2026-08-19 16:29:59 +08:00
Will Miao 0a28500848 fix(loaders): default control_after_generate to fixed on checkpoint/unet loaders
The previous boolean 'control_after_generate': true defaulted the control
widget to 'randomize', silently changing existing workflows into random
model selection on every queue. A string value sets the default mode, so
'fixed' preserves the prior behavior; users opt into randomization
explicitly.
2026-08-19 10:33:23 +08:00
Will Miao fc3f3f3bdb feat(loaders): add control_after_generate random model selection to checkpoint/unet loaders
The Checkpoint/Unet Loader (LoraManager) nodes now support ComfyUI's
built-in control_after_generate mechanism on the ckpt_name/unet_name combos,
letting users pick a random model on every queue with the selected model
written back into the widget (visible, and lockable via the 'fixed' mode).

A base_model input narrows the random pool: a front-end extension fetches
the name/base_model mapping from the new /api/lm/checkpoints/loader-pool
endpoint and filters the combo options, wired through the node callback,
the refreshComboInNodes extension hook, and a graph.onConfigure hook
installed from onAdded (onNodeCreated fires before the node is attached to
a graph, so the graph reference is unavailable there).
2026-08-19 05:13:51 +08:00
Will Miao fa58297973 fix(ui): stop media viewer Escape from closing underlying modal 2026-08-18 20:51:56 +08:00
Will Miao 5d1a22fb8f fix(ui): ignore internal card drags in model card preview drop (#1034)
Tag move-to-folder drags with a custom dataTransfer MIME type so card
preview-drop handlers skip them entirely (no highlight, no upload), and
mark the preview image non-draggable so the browser no longer synthesizes
a File payload when a drag starts on the image. Fixes card-on-card drops
and click-jitter self-drops replacing the preview with itself.
2026-08-18 20:38:29 +08:00
Will Miao d2f50f26f1 feat(ui): redesign model modal showcase as on-demand gallery 2026-08-18 20:38:29 +08:00
hein 4a6042d0b4 fix: include locally available LoRAs in recipe syntax even if deleted from Civitai (#948)
get_recipe_syntax_tokens() previously skipped all LoRAs with
isDeleted=True unconditionally. Now it tries to resolve the file
locally first (via hash index or modelVersionId); only skips if
the LoRA is truly unavailable.

This is a companion fix to #946 (AutoV2 hash matching).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-18 15:10:53 +08:00
Will Miao 846206d958 fix(ui): add model modal backdrop blur to match recipe modal 2026-08-18 09:09:05 +08:00
Will Miao 0daf4924f0 feat(recipes): redesign recipe detail modal with three-column workspace layout
- Three-column layout (preview | generation parameters | resources) with
  independent per-pane scrolling and a content-sized modal shell that
  shrinks to fit short recipes and caps at viewport height for long ones
- Blurred, darker backdrop to focus attention on the modal
- Preview frame hugs the image instead of a fixed-size box
- Move recipe-level 'Send to ComfyUI' into the header actions row to match
  the model detail modal convention; remove the modal 'Copy Recipe Syntax'
  button (context menu action is unaffected)
- Add recipes.actions.sendRecipe i18n keys with translations
- Sync modal test fixtures to the new structure
2026-08-18 09:09:05 +08:00
willmiao d38a3d091d docs: auto-update supporters list in README 2026-08-16 11:47:29 +00:00
Will Miao 94dd08646d chore(release): bump version to v1.2.1 2026-08-16 19:47:15 +08:00
Will Miao 658f88ca48 feat(recipes): add toolbar toggle and settings preview for masonry layout 2026-08-16 15:23:30 +08:00
Will Miao f53352efb2 feat(metadata): collect generation params from Krea two/three stage samplers 2026-08-16 09:53:08 +08:00
Will Miao 38809a9d1b feat(recipes): add filename fallback tier to recipe rematch 2026-08-16 09:17:59 +08:00
Will Miao 395682509c feat(autocomplete): replace /af and /ac toggle abbreviations with full command names 2026-08-15 22:03:54 +08:00
Will Miao ef3e7d7bf4 feat(update): detect CivitAI paidAccess versions and add hide paid updates (#1060)
CivitAI's PaidAccess cutover deprecated the availability=EarlyAccess and
earlyAccessEndsAt signals; gated versions now report availability=Public
with a paidAccess DTO that LoRA Manager previously ignored, so "Hide
Early Access Updates" missed paid/early-access models and downloads
failed with 401.

Parse and persist paidAccess from model-level, bulk, and by-hash
responses; treat timed paid gates as early access and permanent paid
versions as a distinct is_paid state; add a hide_paid_updates setting
with a "Paid" badge in the versions tab; warn before downloading gated
versions. Includes SQLite migration, i18n for all locales, and
backend/frontend tests.
2026-08-15 18:08:14 +08:00
Will Miao c85b6b64a1 feat(recipes): add recently opened sort with modal open tracking
Track recipe modal opens in a separate stats file (never touching recipe
JSON/EXIF), expose a fire-and-forget POST endpoint, and add an 'opened'
sort that hides never-opened recipes as a true recently-opened view.
Includes i18n for all locales and backend/frontend tests.
2026-08-15 11:37:46 +08:00
Will Miao 34c87d4934 refactor(sort): extract seeded random sort helpers into SortDropdown 2026-08-15 09:53:28 +08:00
Will Miao 93472e5d67 feat(recipes): add sort by random option with seeded stable pagination 2026-08-15 09:50:46 +08:00
Will Miao ae185ee714 fix(loaders): correct random checkpoint loader return type annotation
load_checkpoint returns a 4-tuple (MODEL, CLIP, VAE, model_name) since the
random loader exposes the selected model name; the annotation still claimed
a 3-tuple.
2026-08-15 08:51:39 +08:00
Martial Michel 795036275a feat(loaders): add random model selection by base model to checkpoint/unet loaders
Add dedicated Random Checkpoint/Unet Loader (LoraManager) nodes that pick a random model from the indexed pool on every run, optionally filtered by base_model, and expose the selected model name via a STRING output.
2026-08-15 08:48:57 +08:00
Will Miao d43ab6e32f fix(vue-widgets): make text widget clear button undoable via Ctrl+Z (#1056) 2026-08-14 23:16:12 +08:00
Will Miao 280181f92e feat(metadata-overwrite): support wired SAMPLER input on sampler field
The sampler field now accepts either a manual string or a SAMPLER
connection. When wired, the sampler name is extracted from the
KSAMPLER object's sampler_function __name__ (sample_euler -> euler),
with special-casing for dpm_fast/dpm_adaptive local closures and
uni_pc/uni_pc_bh2 function names.

- sampler input declared as "STRING,SAMPLER" with widgetType STRING,
  mirroring the existing model field union pattern
- shared collect_overwrite_params() handles the non-str branch so the
  node and the metadata extractor conversion logic stay in sync;
  unrecognized sampler functions are logged and skipped
- note: ddim is constructed by ComfyUI as euler with random inpaint,
  so the ddim name is unrecoverable and extracts as euler
2026-08-14 15:21:28 +08:00
Will Miao f8d98934ad feat(ui): set preview via drag and drop on model cards (#1034) 2026-08-14 13:01:10 +08:00
Will Miao 303cca0d85 fix(download): accept newer CivitAI file types for primary file selection
Downloads failed with "No suitable file found in metadata" for models whose
only file uses newer CivitAI file types (e.g. 'Enhancement LoRA' for
Anima/AIR image-editing LoRAs) because the primary-file allowlist only
covered legacy types.

- unify the weights-type allowlist as MODEL_WEIGHT_FILE_TYPES
  (py/utils/constants.py) and apply it across download, recipe and
  metadata-refresh lookups
- mirror CivitAI's getPrimaryFile() semantics: prefer weights-type primary,
  fall back to weights files, then trust CivitAI's primary flag (excluding
  non-downloadable artifacts like Config/Archive/Workflow)
- mirror the allowlist in the frontend via shared isModelWeightFile() helper
- add regression tests for the Enhancement LoRA primary-file download,
  primary-flag fallback and weights-over-non-weights-primary preference
2026-08-12 21:14:23 +08:00
Will Miao c2f16784b3 fix(metadata): keep identity selectors from leaking unselected prompts 2026-08-12 19:44:43 +08:00
Will Miao 5bc6d8286c fix(metadata): exclude scalar fields from conditioning provenance inputs 2026-08-12 19:18:46 +08:00
Luna_K 3f8381ffee Fix prompt tracking through conditioning transforms 2026-08-12 19:16:30 +08:00
Will Miao 1ca99294c9 feat(delete): shorten undo window to 20s and make undo toast dismissible 2026-08-12 19:15:03 +08:00
Will Miao 680f0a57f5 fix(update): resolve template path when updating to a different base model (#1059)
Version-tab updates reused the current version's folder, so updating a LoRA
to a version with a different base model (e.g. Illustrious -> Anima) ignored
the download path template and landed in the old version's directory.

When the target version's base model differs from the current local version
and a path template is configured, re-resolve the template under the same
model root. The backend keeps an explicitly provided root when
use_save_dir_as_root is set, so regular downloads still use the default root.
2026-08-12 18:45:54 +08:00
Will Miao 94e3f54571 feat(workflow): exclude text-capable nodes with connected text from send targets
CLIP Text Encode and friends whose text widget is backed by a connected
input cannot have their text changed via the widget (execution reads the
linked input), so sending to them was a silent no-op.

- Registry: compute text_widget_connected capability from the widget's
  backing input link state; has_text_widget drops to false when wired;
  include the flag in the registration fingerprint so link changes
  re-register the affected nodes
- Registry: hook link connect/disconnect (graph events on new litegraph,
  onAfterChange fallback for classic) on root and subgraphs, plus
  subgraph-created for future subgraphs
- applyWidgetUpdate: skip inject_text when the target widget is connected
  and self-heal the registry instead of writing a value that is ignored
- Web UI: drop text_widget_connected nodes from prompt/embedding send
  candidates; show a Mark as -> Send Prompt Target hint toast when no
  candidates remain (new uiHelpers.workflow.noPromptTargets key, synced
  to all locales; zh-CN/zh-TW translated)
- Extract shared resolveTextWidget() used by both the candidate-set
  logic and the write path so the two cannot drift apart
- Tests: workflow registry connection-state registration, subgraph
  handling, fingerprint re-registration, inject_text write/skip paths,
  setup link-change hooks; uiHelpers candidate filtering and hint toast
2026-08-12 16:50:07 +08:00
Will Miao 5c2b2aedcc fix(i18n): complete recipe delete undo warning translations
Translate modals.deleteRecipe.recoverableWarning in all 9 non-English
locales (de, es, fr, he, ja, ko, ru, zh-CN, zh-TW)
2026-08-11 21:20:54 +08:00
Will Miao ebc31fb963 fix(i18n): translate recipe delete undo warning
Move the recipe delete modal's undo warning into modals.deleteRecipe.
recoverableWarning instead of hardcoded English; sync placeholders into
all 9 non-English locales
2026-08-11 21:18:36 +08:00
Will Miao 9659df6ad9 refactor(delete): make undo unconditional, remove undo toggle and button delay
- Remove delete_undo_enabled setting (backend default, frontend state,
  settings modal UI, 10 locales); staged deletes with 30s undo are now
  the only delete path and stale settings keys are silently ignored
- Remove the 1500ms delete-button arm delay (armDeleteButton) from all
  delete modals; misclicks are recoverable via the undo toast
- Delete modal always shows the recoverable warning
- Log the first staged file path in staging log lines for easier support
2026-08-11 21:15:59 +08:00
Will Miao 04d131e9dc docs(delete): correct same-volume guarantee after symlink fix 2026-08-11 19:01:44 +08:00
Will Miao 78fe6282c7 test(delete): symlink and restart regression for staged deletes 2026-08-11 19:00:28 +08:00
Will Miao 0c00ee22fc fix(delete): stage model deletes into the model folder (avoid EXDEV) 2026-08-11 18:48:03 +08:00
Will Miao 5fd4946b1f fix(delete): track staged batches in-process; reconcile at startup 2026-08-11 18:36:30 +08:00
Will Miao f1d3ac0cdc fix(metadata): fill local file facts when self-heal recreates sidecar
Refresh after manual .metadata.json deletion rebuilds the payload without
file_name/size/modified, which are required by BaseModelMetadata.from_dict.
The recreated sidecar then fails to parse and the scanner skips the model.

- load_metadata_payload fills missing file facts from os.stat
- hydrate_model_data restores every missing key from the cache snapshot
  only when the sidecar is missing entirely (disk stays authoritative
  otherwise), preferring the cached import timestamp for modified
- save_metadata fills file facts on write so no write path can produce
  an unparseable sidecar
2026-08-11 14:57:23 +08:00
Will Miao e2c45905f0 test(recipe): await background resort deterministically in pagination tests 2026-08-11 14:09:24 +08:00
Will Miao b2c68e6a65 feat(delete): add undo toasts and harden delete modals 2026-08-11 14:09:10 +08:00
Will Miao eb0f6dd3b6 feat(settings): add delete_undo_enabled toggle 2026-08-11 14:08:55 +08:00
Will Miao 0bf87f9092 chore(i18n): add undo-delete and delete-confirmation strings 2026-08-11 14:08:41 +08:00
Will Miao 1da2433bb2 feat(delete): add undo-delete endpoint and purge scheduling 2026-08-11 14:08:28 +08:00
Will Miao 2d6cf545b9 feat(delete): stage model and recipe deletes for 30s undo 2026-08-11 14:08:15 +08:00
Will Miao 6a259a14fa feat(nodes): flag missing local models at queue and load time (#1057) 2026-08-10 12:31:36 +08:00
Will Miao 41e1fd1e1f feat(download): expose aria2 disk write failure root cause at INFO level
Promote aria2 stderr lines that indicate disk write failures (e.g. the
'cause: No space left on device' line following 'Write disk cache flush
failure') from DEBUG to INFO so the root cause is visible in default logs,
including Windows-specific phrases (file locked by another process, sharing
violation). The same line is rate-limited to one INFO report per 60s window
and the report map is pruned on insert so repeated failures cannot spam the
log or grow memory. All other stderr output stays at DEBUG.
2026-08-10 09:45:13 +08:00
Will Miao 95fb3c7fc9 feat(recipes): add prompt-aware duplicate detection toggle 2026-08-10 00:07:14 +08:00
Will Miao 8237e5f9ea feat(ui): mark repair recipe data entries as deprecated
Add (Deprecated) label suffix to the three context menu entries for
repairing recipe data (global, bulk, single) ahead of their removal.
2026-08-09 15:50:02 +08:00
Will Miao aa75986178 fix(ui): hide context menu separator with no visible items 2026-08-09 15:44:10 +08:00
Will Miao b887922055 fix(i18n): translate rematch metadata strings 2026-08-09 15:33:24 +08:00
Will Miao 68fa0f29c7 feat(recipes): report rematch results with aggregate logs and toast feedback 2026-08-09 14:37:23 +08:00
Will Miao d9d362c9c9 fix(download): self-heal aria2 transfers lost on daemon restart 2026-08-09 12:48:36 +08:00
Will Miao d0bc4be0dc chore(skill): harden lora-manager-e2e for sandboxed E2E 2026-08-09 11:31:04 +08:00
Will Miao 420530f532 feat(ui): add global, bulk and per-recipe rematch actions 2026-08-09 11:31:00 +08:00
Will Miao 3001f0f0ef feat(recipes): add recipe rematch API endpoints 2026-08-09 11:30:50 +08:00
Will Miao b2a1307d23 feat(recipes): add recipe rematch WebSocket progress channel 2026-08-09 11:30:46 +08:00
Will Miao 64da845a58 feat(recipes): add local-only recipe rematch to scanner 2026-08-09 11:30:43 +08:00
Will Miao 27027c4497 refactor(recipes): reuse shared local hash cache in create-from-example 2026-08-08 22:45:29 +08:00
Will Miao 86c85c08ec feat(recipes): pass local hash cache to remote and url recipe imports 2026-08-08 22:13:19 +08:00
Will Miao 196c8ffc3e feat(recipes): match civitai image hash sections against local hash cache 2026-08-08 22:12:47 +08:00
Will Miao cfc95ee02a feat(recipes): pass local hash cache through analysis recipe parsing 2026-08-08 22:11:50 +08:00
Will Miao 479fa36997 feat(recipes): add version-cached local hash cache builder 2026-08-08 22:04:09 +08:00
Will Miao 3e1216e9bc feat(recipes): add cache version counter to model scanners 2026-08-08 21:57:36 +08:00
Will Miao 007883b7d1 fix(recipes): backfill lora cache item by autov2/autov3 hash too 2026-08-08 21:51:34 +08:00
Will Miao dc9200a12c fix(recipes): match recipe-format lora cache item by autov2/autov3 hash 2026-08-08 21:50:33 +08:00
Will Miao d2f955266d fix(types): resolve pre-existing basedpyright errors in tests
Fix ~790 basedpyright errors across the test suite:
- Type stub subclasses of real production classes with super().__init__()
- Add missing generic type arguments and Dict[str, Any] annotations
- Add None guards before subscript/member access
- Adapt tests to production API changes (removed dead handlers,
  PersistentModelCache.get_default, _i18n_filter_added location)
2026-08-08 20:12:59 +08:00
Will Miao 8e724538bd fix(types): resolve pre-existing basedpyright errors in py/ and standalone.py
Fix ~950 basedpyright errors across the backend:
- Convert ineffective # type: ignore comments to # pyright: ignore[rule]
- Add missing generic type arguments (Dict[str, Any], list[Any], ...)
- Annotate dynamic dict literals and runtime-initialized attributes
- Widen CivitAI provider tuple signatures in recipe parsers
- Remove dead LoraRoutes handlers calling nonexistent LoraService methods
- Suppress unavoidable ServiceRegistry import cycles (basedpyright counts
  function-local imports as cycle edges)
2026-08-08 20:12:52 +08:00
Will Miao 6fcdeb799d feat(metadata): resolve AutoV3 at download time without waiting for backfill
- Read AutoV3 directly from the downloaded file's own file_info hashes
  (no SHA256 cross-matching against version_info.files, so the value is
  captured even when the API omits SHA256)
- Extract normalize_autov3() validation helper shared with the
  sha256-matching autov3_from_civitai_files path
- Fall back to the embedded safetensors header hash at download
  completion; mark '' (checked-unavailable) so the startup backfill
  query (autov3 IS NULL) never revisits the row
- Clear archive-level AutoV3 for zip-extracted models so per-file
  header resolution applies to every extracted model
2026-08-08 15:20:43 +08:00
Will Miao 97b9b1f62b feat(metadata): add CivitAI AutoV3 hash support across all storage layers
- Three-state autov3 field (not-checked / checked-unavailable / 12-hex value)
  in .metadata.json sidecars, in-memory ModelHashIndex, and SQLite
  (models.autov3 column + autov3_index table) with column-presence migration
- Background self-terminating backfill for legacy rows: per-model-type
  concurrency guard, executor-offloaded I/O, Civitai-first resolution
  (SHA256-matched version file) falling back to the embedded safetensors
  header hash
- Civitai-first propagation on metadata refresh, scan, and download paths;
  reject the empty-string SHA256 placeholder and strip OneTrainer 0x prefix
- List API hash filters and hash index lookups accept 12-char AutoV3
- Cap safetensors header reads at 64 MiB to prevent crafted-file allocation
- Prevent stale AutoV3 mappings on file replacement while preserving them on
  same-file re-registration (lazy-hash completion)
2026-08-08 14:30:34 +08:00
Will Miao 4bf9a4b640 refactor(download): rename locationStep id to downloadLocationStep
The download modal's step shared the 'locationStep' id with the import
modal, so getElementById('locationStep') could resolve to the wrong
element depending on template include order. The import flow relied on
an injected display:block !important rule to work around it.

Rename the download modal's step id and update all references so each
modal owns a unique step id.
2026-08-08 08:50:36 +08:00
Will Miao c5088772e8 fix(ui): pin import modal action buttons with sticky footer
Make the import modal a flex column with a scrollable step area so the
Back/Import buttons stay visible on short viewports (1080p / 150% zoom)
instead of being cut off at the bottom of the scroll flow.

Also reset step scroll positions via class since 'locationStep' has a
duplicate id in the download modal template.
2026-08-08 08:49:20 +08:00
Will Miao 56acefbd6c feat(autocomplete): search loras within active filters of LoRA Manager page
Add /af and /noaf toggle commands (plus /activefilters aliases) to the
loras autocomplete widget. When enabled (default off), suggestions are
matched within the active filters (folder, base model, tags, auto-tags,
license, tag logic) persisted by the LoRA Manager page in localStorage,
keeping the match pool consistent with the list endpoint, including the
global show_only_sfw setting.

Backend: /lm/{prefix}/relative-paths accepts the filter query params and
pre-filters the scanner cache with ModelFilterSet. The presence of the
recursive param signals the filter pipeline to run even without concrete
filters so global settings stay in parity with the list endpoint.
2026-08-07 20:07:34 +08:00
Will Miao 5ab06c4aae docs: rename "Standalone Web UI" to "LoRA Manager Web UI" in AGENTS.md 2026-08-07 17:57:01 +08:00
Will Miao c11f4b5c68 feat(ui): widen filter panel and preset name limit 2026-08-07 16:53:14 +08:00
Will Miao 86376284f4 fix(ui): clamp filter panel height to viewport 2026-08-07 16:53:04 +08:00
Will Miao 2b8a2fc7d8 feat(filters): remove preset count limit 2026-08-07 16:52:53 +08:00
Will Miao f26e1b41c8 fix(i18n): translate zh-TW api key placeholder 2026-08-07 16:25:40 +08:00
Will Miao c1671af99f feat(downloads): translate batch download summary strings 2026-08-07 16:23:55 +08:00
Will Miao ac7707d0f6 fix(cards): clear model-card min-width on the item element itself 2026-08-07 16:09:51 +08:00
Will Miao 381cd710a2 feat(recipes): translate recipes layout setting strings 2026-08-07 15:36:30 +08:00
Will Miao ad0d18cb79 chore: ignore .playwright-mcp working directory 2026-08-07 15:33:43 +08:00
Will Miao 7980ee77d0 perf(recipes): batch preview dimension reads via asyncio.gather 2026-08-07 15:31:13 +08:00
Will Miao 916b8bb327 fix(recipes): skip stale scroller re-enable on deferred layout switch 2026-08-07 14:03:13 +08:00
Will Miao 87e3d4dea9 feat(recipes): wire recipes layout switch event and rebuild 2026-08-07 12:49:30 +08:00
Will Miao 76a913f5e0 feat(recipes): complete MasonryScroller public API parity with VirtualScroller 2026-08-07 12:40:28 +08:00
Will Miao d8c192e647 feat(recipes): branch masonry scroller instantiation for recipes page 2026-08-07 12:38:17 +08:00
Will Miao c453437620 feat(recipes): add MasonryScroller with column-based virtual scrolling 2026-08-07 12:31:13 +08:00
Will Miao 720fa6d909 feat(recipes): expose preview width/height in recipe listing API 2026-08-07 11:59:26 +08:00
Will Miao b4f71089f4 feat(recipes): add recipes_layout setting (grid|masonry) with i18n 2026-08-07 11:49:12 +08:00
Will Miao 83e6657ead feat(recipes): add get_image_dimensions helper with LRU cache 2026-08-07 11:47:28 +08:00
Will Miao 7ea6df4111 feat(downloads): default to latest version when URL lacks modelVersionId
Auto-select the first (newest) version for URLs without an explicit
modelVersionId, matching the existing batch flow, so users can proceed
to location/download without manually picking a version.
2026-08-07 11:24:45 +08:00
Will Miao d9ab92602a feat(downloads): show failure summary modal for single downloads too 2026-08-07 10:49:14 +08:00
pixelpaws 5ffadaed31 Merge pull request #1054 from willmiao/feat/gemini-provider
feat(llm): add Gemini as a preset AI provider
2026-08-07 10:30:45 +08:00
Will Miao 24f5f7df5d feat(llm): add Gemini as a preset AI provider 2026-08-07 10:27:51 +08:00
Will Miao daf01fb1d6 feat(downloads): show batch download summary with failure details and retry 2026-08-07 10:23:17 +08:00
Will Miao 0f11b6def9 fix(recipes): allow recipes storage path on a different drive (Windows)
os.path.commonpath raises ValueError for paths on different Windows
drives. Treat that as no common root so cross-drive recipes migrations
succeed instead of failing with 'Invalid recipes path change'.
2026-08-06 22:18:24 +08:00
384 changed files with 40547 additions and 4274 deletions
+209 -37
View File
@@ -1,47 +1,145 @@
--- ---
name: lora-manager-e2e name: lora-manager-e2e
description: End-to-end testing and validation for LoRa Manager features. Use when performing automated E2E validation of LoRa Manager standalone mode, including starting/restarting the server, using Chrome DevTools MCP to interact with the web UI at http://127.0.0.1:8188/loras, and verifying frontend-to-backend functionality. Covers workflow validation, UI interaction testing, and integration testing between the standalone Python backend and the browser frontend. description: End-to-end testing and validation for LoRa Manager features. Use when performing automated E2E validation of LoRa Manager standalone mode in a SANDBOXED, disposable configuration: check the port, start/restart the standalone server on a free port, use Chrome DevTools MCP to interact with the web UI (http://127.0.0.1:{PORT}/loras), and verify frontend-to-backend functionality. Covers workflow validation, UI interaction testing, and integration testing between the standalone Python backend and the browser frontend. Trigger keywords: E2E, standalone, Chrome DevTools MCP, lora-manager-e2e, sandbox.
--- ---
# LoRa Manager E2E Testing # LoRa Manager E2E Testing
This skill provides workflows and utilities for end-to-end testing of LoRa Manager using Chrome DevTools MCP. This skill provides workflows and utilities for end-to-end testing of LoRa Manager using Chrome DevTools MCP.
## Conventions Used in This Document
- **`{PORT}`**: The server port. The default candidate is `8188`, but **`8188` is commonly occupied by a live ComfyUI process** and MUST NOT be assumed to be free. Always check availability first (see [Port Selection](#port-selection)) and use a free port (e.g. `8199`) for the E2E run. Substitute the actual port for every `{PORT}` in the commands below.
- **`<repo-root>`**: The repository/worktree root. Always run commands from the repo or worktree root; never assume a specific absolute path (paths such as `/home/<user>/...` differ per machine). The E2E scripts resolve the project root themselves, but fixture/settings paths are relative to `<repo-root>`.
## SANDBOX (MANDATORY)
> **Read this section before running anything.** Every E2E run MUST target a throwaway sandbox, never the real user data. A fresh subagent that skips this section WILL permanently mutate real user recipes.
1. **Portable settings**: create `<repo-root>/settings.json` (gitignored) with `"use_portable_settings": true` plus sandboxed `folder_paths` (lora/checkpoint roots) and `recipes_path`. This keeps the configuration inside the repo instead of the real user config dir (`~/.config/ComfyUI-LoRA-Manager/settings.json`).
2. **Sandboxed paths**: point `folder_paths` / `recipes_path` / `example_images_path` at disposable dirs — e.g. under `/tmp/opencode/<plan-name>-e2e/` (or worktree-local dirs). NEVER point the E2E at the real library (`~/models/...`), real recipe dir, or real settings.
3. **Never touch the real config**: the real user config at `~/.config/ComfyUI-LoRA-Manager/settings.json` and the real recipe dir must remain byte-identical before and after the run.
4. **Record real-data protection proof** before starting and after finishing:
```bash
# BEFORE: snapshot real config + recipe library state
sha256sum ~/.config/ComfyUI-LoRA-Manager/settings.json > /tmp/opencode/<plan>-e2e/settings.before.sha256
ls ~/models/recipes/*.recipe.json 2>/dev/null | wc -l > /tmp/opencode/<plan>-e2e/recipes-count.before.txt
find ~/models/recipes -name '*.recipe.json' -newermt "$(date -Iseconds)" | head # expect empty after run
# AFTER: record again, then diff the two snapshots. Any change = the run leaked into real data.
```
Also confirm `<repo-root>/git status` stays clean for `settings.json`/`cache/` (both are gitignored).
### Portable Settings Example
```json
{
"use_portable_settings": true,
"folder_paths": {
"loras": ["/tmp/opencode/<plan>-e2e/models/loras"],
"checkpoints": ["/tmp/opencode/<plan>-e2e/models/checkpoints"],
"unet": ["/tmp/opencode/<plan>-e2e/models/checkpoints"],
"diffusers": []
},
"recipes_path": "/tmp/opencode/<plan>-e2e/recipes",
"example_images_path": "/tmp/opencode/<plan>-e2e/example_images"
}
```
The scanner computes and persists model hashes during the library scan, so the sandbox model dirs just need the model files + `.metadata.json` sidecars (see [Fixture + Fresh-State Guidance](#fixture--fresh-state-guidance)).
## Time Budgets & Abort Guidance
A fresh subagent should complete a sandboxed standalone E2E **in well under 30 minutes**. Budget each phase:
| Phase | Expected duration | Abort if |
| --- | --- | --- |
| Port check + sandbox setup | < 2 min | — |
| Server start (detached) + readiness | < 30 s | > 60 s (2x) → stop |
| Chrome DevTools MCP connect | < 1 min | > 2 min → stop |
| Per entry-point run (after fixtures ready) | < 5 min | > 10 min (2x) → stop |
| Fixture reset + cache clear between runs | < 1 min | > 2 min → stop |
**Abort rule**: if a phase exceeds ~2x its budget, OR any single tool call fails/retries 3+ times in a row, **STOP**. Do not loop or retry blindly. Report `BLOCKED` with: the phase, the last observed state (server PID + `ss -tlnp` output, page snapshot, last API response), and the suspected cause. Record the partial state as evidence; a clean BLOCKED report is more valuable than an hour of retries.
## Prerequisites ## Prerequisites
- LoRa Manager project cloned and dependencies installed (`pip install -r requirements.txt`) - LoRa Manager project cloned and dependencies installed (`pip install -r requirements.txt`) — run everything from `<repo-root>`
- Chrome browser available for debugging - Chrome browser available for debugging
- Chrome DevTools MCP connected - Chrome DevTools MCP connected
- `ss` (or `lsof`/`netstat`) available for port checks: `ss -tlnp`
## Quick Start Workflow ## Port Selection
### 1. Start LoRa Manager Standalone `8188` is only the *default candidate*. Verify it is actually free before every run:
```python
# Use the provided script to start the server
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port 8188
```
Or manually:
```bash
cd /home/miao/workspace/ComfyUI/custom_nodes/ComfyUI-Lora-Manager
python standalone.py --port 8188
```
Wait for server ready message before proceeding.
### 2. Open Chrome Debug Mode
```bash ```bash
# Chrome with remote debugging on port 9222 # Is anything listening on 8188?
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:8188/loras ss -tlnp | grep ':8188' || echo "8188 is free"
``` ```
### 3. Connect Chrome DevTools MCP - If a process holds `8188` (e.g. a live ComfyUI — pid 6575 on this machine), pick a different free port, e.g. `8199`:
```bash
ss -tlnp | grep ':8199' || echo "8199 is free"
```
- **Never** kill a process you did not start for this E2E. The live ComfyUI is off-limits. Pick a free port instead.
- Use your chosen port for **all** subsequent commands (server, Chrome launch, browser URLs).
Ensure the MCP server is connected to Chrome at `http://localhost:9222`. ## Quick Start Workflow (sandboxed)
### 4. Navigate and Interact ### 1. Prepare the sandbox
```bash
cd <repo-root> # ALWAYS run from the repo/worktree root
mkdir -p /tmp/opencode/<plan>-e2e/models/{loras,checkpoints}
mkdir -p /tmp/opencode/<plan>-e2e/{recipes,example_images,recipes-before}
# write <repo-root>/settings.json per the portable-settings example above
# record real-data protection proof (see SANDBOX section)
```
### 2. Check port availability
```bash
ss -tlnp | grep ':{PORT}' || echo "port {PORT} is free"
```
If `{PORT}` is occupied by an unrelated process, pick a free one and use it everywhere below. When in doubt use `8199`.
### 3. Start LoRa Manager Standalone (detached)
The standalone server **dies with the shell unless launched fully detached** — a plain background `&` from the bash tool is killed when the tool call returns. Launch via the helper script:
```bash
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --wait --timeout 30 --detach
```
Or manually (equivalent detached form):
```bash
setsid nohup python standalone.py --port {PORT} --host 127.0.0.1 < /dev/null \
>> /tmp/opencode/<plan>-e2e/server.log 2>&1 &
echo "started" # record the printed/pidfile PID for cleanup
```
Verify it is listening **before** proceeding (readiness poll is not a substitute for this):
```bash
ss -tlnp | grep ':{PORT}'
```
Record the server PID for cleanup: the helper script writes it to `/tmp/lora-manager-e2e-server-{PORT}.pid`; a manual `setsid` launch has no pidfile, so capture it explicitly (e.g. from `ss -tlnp`).
### 4. Open Chrome Debug Mode
```bash
# Chrome with remote debugging on port 9222 (note the {PORT} URL)
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:{PORT}/loras
```
### 5. Connect Chrome DevTools MCP
Ensure the MCP server is connected to Chrome at `http://localhost:9222`. Verify with `list_pages` — if it fails with "browser is already running", see [Chrome DevTools MCP Troubleshooting](#chrome-devtools-mcp-troubleshooting).
### 6. Navigate and Interact
Use Chrome DevTools MCP tools to: Use Chrome DevTools MCP tools to:
- Take snapshots: `take_snapshot` - Take snapshots: `take_snapshot`
@@ -56,7 +154,7 @@ Use Chrome DevTools MCP tools to:
```python ```python
# Navigate to LoRA list page # Navigate to LoRA list page
navigate_page(type="url", url="http://127.0.0.1:8188/loras") navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
# Wait for page to load # Wait for page to load
wait_for(text="LoRAs", timeout=10000) wait_for(text="LoRAs", timeout=10000)
@@ -68,9 +166,10 @@ snapshot = take_snapshot()
### Pattern: Restart Server for Configuration Changes ### Pattern: Restart Server for Configuration Changes
```python ```python
# Stop current server (if running) # Stop current server (if running), start with new configuration.
# Start with new configuration # --restart only kills the E2E server this script started before (via its pidfile);
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port 8188 --restart # it refuses to blindly kill unrelated processes on the port.
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --restart --wait --detach
# Wait and refresh browser # Wait and refresh browser
navigate_page(type="reload", ignoreCache=True) navigate_page(type="reload", ignoreCache=True)
@@ -130,24 +229,96 @@ click(uid="modal-submit-button")
wait_for(text="Success", timeout=5000) wait_for(text="Success", timeout=5000)
``` ```
## Fixture + Fresh-State Guidance
For rematch/repair E2E runs, seed the **sandboxed** `recipes_path` with hand-written fixture recipes. Rules (validated by the task-8 E2E):
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.
### Fresh state between entry-point runs
Each entry point (global / per-recipe / selection-bulk) must start from the same deleted state. Between runs:
```bash
# 1. Reset fixtures to the before-state snapshot (copy back from recipes-before/)
cp /tmp/opencode/<plan>-e2e/recipes-before/*.recipe.json /tmp/opencode/<plan>-e2e/recipes/
# 2. Clear the recipe/FTS caches so the stale in-memory/library state is gone
rm -f <repo-root>/cache/recipe/*.sqlite
rm -rf <repo-root>/cache/fts/*
# 3. Restart the server (fresh process, fresh scan)
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --restart --wait --timeout 30 --detach
# 4. Re-verify server listening + reload the browser page
```
## Server Lifecycle
- **Detached launch is mandatory**: the standalone server dies with the shell unless launched via `setsid` (or the helper script's `--detach`). Use `setsid nohup python standalone.py --port {PORT} --host 127.0.0.1 ... < /dev/null &`.
- **Verify with `ss -tlnp`** after every (re)start; do not proceed on a blind "server starting" message.
- **Never kill pre-existing processes** — only kill the E2E server PID you started (`start_server.py --restart` kills only PIDs it manages via its pidfile). The live ComfyUI or a stale QA Chrome must never be killed as part of cleanup unless explicitly identified as such (see Chrome troubleshooting).
- **Record your PID for cleanup**: note the PID printed/pidfile, and stop exactly that PID at the end (`kill <PID>`, then confirm with `ss -tlnp` that `{PORT}` is released).
## Chrome DevTools MCP Troubleshooting
### Stale profile lock ("browser is already running" / `list_pages` fails)
A Chrome profile can be held by a stale Chrome from a prior MCP session, which makes `list_pages` fail with "browser is already running":
1. Identify the stale Chrome — it owns the profile dir in `--user-data-dir` (e.g. `~/.config/chrome-dev-profile`). Find its process:
```bash
ps -ef | grep -i '[c]hrome.*user-data-dir'
```
2. Confirm it is a QA Chrome from a completed task (its parent is an old MCP/browser process, it is NOT the live ComfyUI server, and it is NOT your current MCP instance).
3. Kill ONLY that stale Chrome:
```bash
kill <stale-chrome-pid>
```
Never kill the live server or unrelated processes.
4. Retry `list_pages`. The current MCP will spawn a fresh browser.
### Screenshot-write restrictions
The chrome-devtools MCP may refuse to write into paths outside its configured workspace roots (e.g. the worktree `.omo/evidence/...` canonicalizing to an unmapped path). Workaround:
```bash
# 1. Save the screenshot to /tmp via the MCP
# take_screenshot(filePath="/tmp/<plan>-e2e/recipe-b-after.png", format="png")
# 2. Copy it into the evidence dir from the shell
mkdir -p <repo-root>/.omo/evidence/screenshots
cp /tmp/<plan>-e2e/recipe-b-after.png <repo-root>/.omo/evidence/screenshots/
```
## 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.
## Available Scripts ## Available Scripts
### scripts/start_server.py ### scripts/start_server.py
Starts or restarts the LoRa Manager standalone server. Starts or restarts the LoRa Manager standalone server for E2E testing.
```bash ```bash
python scripts/start_server.py [--port PORT] [--restart] [--wait] python scripts/start_server.py [--port PORT] [--restart] [--wait] [--timeout SECONDS] [--detach]
``` ```
Options: Options:
- `--port`: Server port (default: 8188) - `--port`: Server port (default: 8188). The script exits early with a clear message if the port is already in use by an unrelated process.
- `--restart`: Kill existing server before starting - `--restart`: Kill the E2E server this script previously managed (tracked via `/tmp/lora-manager-e2e-server-{PORT}.pid`) before starting. If unrelated processes still hold the port after that, the script reports them and aborts instead of killing them.
- `--wait`: Wait for server to be ready before exiting - `--wait`: Wait for the server to be ready before exiting.
- `--timeout`: Readiness wait timeout in seconds (default: 30).
- `--detach`: Launch the server fully detached (`setsid`-style, survives shell death — REQUIRED for E2E). Default off: a normal background process that dies with the shell.
### scripts/wait_for_server.py ### scripts/wait_for_server.py
Polls server until ready or timeout. Polls the server until ready or timeout.
```bash ```bash
python scripts/wait_for_server.py [--port PORT] [--timeout SECONDS] python scripts/wait_for_server.py [--port PORT] [--timeout SECONDS]
@@ -196,6 +367,7 @@ results = performance_stop_trace()
## Cleanup ## Cleanup
Always ensure proper cleanup after tests: Always ensure proper cleanup after tests:
1. Stop the standalone server 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) 2. Close browser pages (keep at least one open).
3. Clear temporary data if needed 3. Remove the sandbox: `rm -rf /tmp/opencode/<plan>-e2e` and `<repo-root>/settings.json` + `<repo-root>/cache` (both gitignored).
4. Re-run the real-data protection check from the SANDBOX section and record the result in your evidence.
@@ -2,11 +2,13 @@
Quick reference for common MCP commands used in LoRa Manager E2E testing. 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 ## Navigation
```python ```python
# Navigate to LoRA list page # Navigate to LoRA list page
navigate_page(type="url", url="http://127.0.0.1:8188/loras") navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
# Reload page with cache clear # Reload page with cache clear
navigate_page(type="reload", ignoreCache=True) navigate_page(type="reload", ignoreCache=True)
@@ -179,7 +181,7 @@ pages = list_pages()
select_page(pageId=0, bringToFront=True) select_page(pageId=0, bringToFront=True)
# Create new page # Create new page
new_page(url="http://127.0.0.1:8188/loras") new_page(url="http://127.0.0.1:{PORT}/loras")
# Close page (keep at least one open!) # Close page (keep at least one open!)
close_page(pageId=1) close_page(pageId=1)
@@ -261,7 +263,7 @@ drag(from_uid="draggable-item", to_uid="drop-zone")
### Verify LoRA Cards Loaded ### Verify LoRA Cards Loaded
```python ```python
navigate_page(type="url", url="http://127.0.0.1:8188/loras") navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
wait_for(text="LoRAs", timeout=10000) wait_for(text="LoRAs", timeout=10000)
# Check if cards loaded # Check if cards loaded
@@ -322,3 +324,37 @@ navigate_page(type="reload")
errors = list_console_messages(types=["error"]) errors = list_console_messages(types=["error"])
assert len(errors) == 0, f"Console errors: {errors}" 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.
@@ -2,6 +2,14 @@
This document provides detailed test scenarios for end-to-end validation of LoRa Manager features. 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 ## Table of Contents
1. [LoRA List Page](#lora-list-page) 1. [LoRA List Page](#lora-list-page)
@@ -19,7 +27,7 @@ This document provides detailed test scenarios for end-to-end validation of LoRa
**Objective**: Verify the LoRA list page loads correctly and displays models. **Objective**: Verify the LoRA list page loads correctly and displays models.
**Steps**: **Steps**:
1. Navigate to `http://127.0.0.1:8188/loras` 1. Navigate to `http://127.0.0.1:{PORT}/loras`
2. Wait for page title "LoRAs" to appear 2. Wait for page title "LoRAs" to appear
3. Take snapshot to verify: 3. Take snapshot to verify:
- Header with "LoRAs" title is visible - Header with "LoRAs" title is visible
@@ -134,7 +142,7 @@ evaluate_script(function="""
**Objective**: Verify recipes page loads and displays recipes. **Objective**: Verify recipes page loads and displays recipes.
**Steps**: **Steps**:
1. Navigate to `http://127.0.0.1:8188/recipes` 1. Navigate to `http://127.0.0.1:{PORT}/recipes`
2. Wait for "Recipes" title 2. Wait for "Recipes" title
3. Take snapshot 3. Take snapshot
@@ -176,7 +184,7 @@ evaluate_script(function="""
**Objective**: Verify settings page displays correctly. **Objective**: Verify settings page displays correctly.
**Steps**: **Steps**:
1. Navigate to `http://127.0.0.1:8188/settings` 1. Navigate to `http://127.0.0.1:{PORT}/settings`
2. Wait for "Settings" title 2. Wait for "Settings" title
3. Take snapshot 3. Take snapshot
@@ -190,7 +198,7 @@ evaluate_script(function="""
1. Navigate to settings page 1. Navigate to settings page
2. Change a setting (e.g., default view mode) 2. Change a setting (e.g., default view mode)
3. Save settings 3. Save settings
4. Restart server: `python scripts/start_server.py --restart --wait` 4. Restart server: `python scripts/start_server.py --port {PORT} --restart --wait --timeout 30 --detach`
5. Refresh browser page 5. Refresh browser page
6. Navigate to settings 6. Navigate to settings
@@ -8,11 +8,18 @@ This script shows how to:
3. Verify functionality end-to-end 3. Verify functionality end-to-end
Note: This is a template. Actual execution requires Chrome DevTools MCP. 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 subprocess
import sys import sys
import time
# 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(): def run_test():
@@ -22,12 +29,12 @@ def run_test():
print("LoRa Manager E2E Test Example") print("LoRa Manager E2E Test Example")
print("=" * 60) print("=" * 60)
# Step 1: Start server # Step 1: Start server (detached so it survives the shell)
print("\n[1/5] Starting LoRa Manager standalone server...") print("\n[1/5] Starting LoRa Manager standalone server...")
result = subprocess.run( result = subprocess.run(
[sys.executable, "start_server.py", "--port", "8188", "--wait", "--timeout", "30"], [sys.executable, "start_server.py", "--port", PORT, "--wait", "--timeout", "30", "--detach"],
capture_output=True, capture_output=True,
text=True text=True,
) )
if result.returncode != 0: if result.returncode != 0:
print(f"Failed to start server: {result.stderr}") print(f"Failed to start server: {result.stderr}")
@@ -36,46 +43,55 @@ def run_test():
# Step 2: Open Chrome (manual step - show command) # Step 2: Open Chrome (manual step - show command)
print("\n[2/5] Open Chrome with debug mode:") print("\n[2/5] Open Chrome with debug mode:")
print("google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:8188/loras") 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)") print("(In actual test, this would be automated via MCP)")
# Step 3: Navigate and verify page load # Step 3: Navigate and verify page load
print("\n[3/5] Page Load Verification:") print("\n[3/5] Page Load Verification:")
print(""" print(
f"""
MCP Commands to execute: MCP Commands to execute:
1. navigate_page(type="url", url="http://127.0.0.1:8188/loras") 1. navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
2. wait_for(text="LoRAs", timeout=10000) 2. wait_for(text="LoRAs", timeout=10000)
3. snapshot = take_snapshot() 3. snapshot = take_snapshot()
""") """
)
# Step 4: Test search functionality # Step 4: Test search functionality
print("\n[4/5] Search Functionality Test:") print("\n[4/5] Search Functionality Test:")
print(""" print(
"""
MCP Commands to execute: MCP Commands to execute:
1. fill(uid="search-input", value="test") 1. fill(uid="search-input", value="test")
2. press_key(key="Enter") 2. press_key(key="Enter")
3. wait_for(text="Results", timeout=5000) 3. wait_for(text="Results", timeout=5000)
4. result = evaluate_script(function=""" 4. result = evaluate_script(function=`
() => { () => {
const cards = document.querySelectorAll('.lora-card'); const cards = document.querySelectorAll('.lora-card');
return { count: cards.length }; return { count: cards.length };
} }
""") `)
""") """
)
# Step 5: Verify API # Step 5: Verify API
print("\n[5/5] API Verification:") print("\n[5/5] API Verification:")
print(""" print(
"""
MCP Commands to execute: MCP Commands to execute:
1. api_result = evaluate_script(function=""" 1. api_result = evaluate_script(function=`
async () => { async () => {
const response = await fetch('/loras/api/list'); const response = await fetch('/loras/api/list');
const data = await response.json(); const data = await response.json();
return { count: data.length, status: response.status }; return { count: data.length, status: response.status };
} }
""") `)
2. Verify api_result['status'] == 200 2. Verify api_result['status'] == 200
""") """
)
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("Test flow completed!") print("Test flow completed!")
@@ -91,29 +107,31 @@ def example_restart_flow():
print("Example: Server Restart Flow") print("Example: Server Restart Flow")
print("=" * 60) print("=" * 60)
print(""" print(
f"""
Scenario: Change setting and verify after restart Scenario: Change setting and verify after restart
Steps: Steps:
1. Navigate to settings page 1. Navigate to settings page
- navigate_page(type="url", url="http://127.0.0.1:8188/settings") - navigate_page(type="url", url="http://127.0.0.1:{PORT}/settings")
2. Change a setting (e.g., theme) 2. Change a setting (e.g., theme)
- fill(uid="theme-select", value="dark") - fill(uid="theme-select", value="dark")
- click(uid="save-settings-button") - click(uid="save-settings-button")
3. Restart server 3. Restart server
- subprocess.run([python, "start_server.py", "--restart", "--wait"]) - subprocess.run([python, "start_server.py", "--port", "{PORT}", "--restart", "--wait", "--detach"])
4. Refresh browser 4. Refresh browser
- navigate_page(type="reload", ignoreCache=True) - navigate_page(type="reload", ignoreCache=True)
- wait_for(text="LoRAs", timeout=15000) - wait_for(text="LoRAs", timeout=15000)
5. Verify setting persisted 5. Verify setting persisted
- navigate_page(type="url", url="http://127.0.0.1:8188/settings") - navigate_page(type="url", url="http://127.0.0.1:{PORT}/settings")
- theme = evaluate_script(function="() => document.querySelector('#theme-select').value") - theme = evaluate_script(function="() => document.querySelector('#theme-select').value")
- assert theme == "dark" - assert theme == "dark"
""") """
)
def example_modal_interaction(): def example_modal_interaction():
@@ -123,7 +141,8 @@ def example_modal_interaction():
print("Example: Modal Dialog Interaction") print("Example: Modal Dialog Interaction")
print("=" * 60) print("=" * 60)
print(""" print(
"""
Scenario: Add new LoRA via modal Scenario: Add new LoRA via modal
Steps: Steps:
@@ -143,7 +162,8 @@ def example_modal_interaction():
4. Verify success 4. Verify success
- wait_for(text="Successfully added", timeout=5000) - wait_for(text="Successfully added", timeout=5000)
- snapshot = take_snapshot() - snapshot = take_snapshot()
""") """
)
def example_network_monitoring(): def example_network_monitoring():
@@ -153,12 +173,13 @@ def example_network_monitoring():
print("Example: Network Request Monitoring") print("Example: Network Request Monitoring")
print("=" * 60) print("=" * 60)
print(""" print(
f"""
Scenario: Verify API calls during user interaction Scenario: Verify API calls during user interaction
Steps: Steps:
1. Clear network log (implicit on navigation) 1. Clear network log (implicit on navigation)
- navigate_page(type="url", url="http://127.0.0.1:8188/loras") - navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
2. Perform action that triggers API call 2. Perform action that triggers API call
- fill(uid="search-input", value="character") - fill(uid="search-input", value="character")
@@ -175,7 +196,8 @@ def example_network_monitoring():
- if search_requests: - if search_requests:
details = get_network_request(reqid=search_requests[0]["reqid"]) details = get_network_request(reqid=search_requests[0]["reqid"])
- Verify request method, response status, etc. - Verify request method, response status, etc.
""") """
)
if __name__ == "__main__": if __name__ == "__main__":
@@ -1,15 +1,78 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Start or restart LoRa Manager standalone server for E2E testing. Start or restart LoRa Manager standalone server for E2E testing.
Backward-compatible CLI: --port, --restart, --wait, --timeout all work as before.
New options: --detach (setsid-style fully detached launch, survives shell death).
Safety rules implemented here:
- Never kill processes the script did not start. The script tracks the PIDs it
manages in a pidfile (/tmp/lora-manager-e2e-server-{PORT}.pid).
- If the port is held by an unrelated process (e.g. a live ComfyUI) the script
reports the conflict and exits early instead of killing it.
- --restart only kills managed PIDs; if unrelated processes still hold the port
afterwards, the script reports them and aborts.
""" """
from __future__ import annotations
import argparse import argparse
import os
import signal
import socket
import subprocess import subprocess
import sys import sys
import time import time
import socket
import signal PIDFILE_PREFIX = "/tmp/lora-manager-e2e-server"
import os
def pidfile_path(port: int) -> str:
"""Path of the pidfile that records PIDs this script started for a port."""
return f"{PIDFILE_PREFIX}-{port}.pid"
def read_managed_pids(port: int) -> list[int]:
"""Read PIDs this script previously managed for the port (may be stale)."""
path = pidfile_path(port)
if not os.path.exists(path):
return []
try:
with open(path, "r", encoding="utf-8") as fh:
return [int(line.strip()) for line in fh if line.strip().isdigit()]
except (OSError, ValueError):
return []
def write_managed_pids(port: int, pids: list[int]) -> None:
"""Record PIDs this script manages for the port."""
try:
with open(pidfile_path(port), "w", encoding="utf-8") as fh:
for pid in pids:
fh.write(f"{pid}\n")
except OSError as exc:
print(f"Warning: could not write pidfile for port {port}: {exc}")
def clear_managed_pids(port: int) -> None:
"""Remove the pidfile for the port (no longer managed)."""
path = pidfile_path(port)
try:
if os.path.exists(path):
os.remove(path)
except OSError as exc:
print(f"Warning: could not remove pidfile {path}: {exc}")
def process_alive(pid: int) -> bool:
"""Return True if a process with the given pid exists."""
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True # exists but owned by someone else
def find_server_process(port: int) -> list[int]: def find_server_process(port: int) -> list[int]:
@@ -19,7 +82,7 @@ def find_server_process(port: int) -> list[int]:
["lsof", "-ti", f":{port}"], ["lsof", "-ti", f":{port}"],
capture_output=True, capture_output=True,
text=True, text=True,
check=False check=False,
) )
if result.returncode == 0 and result.stdout.strip(): if result.returncode == 0 and result.stdout.strip():
return [int(pid) for pid in result.stdout.strip().split("\n") if pid] return [int(pid) for pid in result.stdout.strip().split("\n") if pid]
@@ -30,7 +93,7 @@ def find_server_process(port: int) -> list[int]:
["netstat", "-tlnp"], ["netstat", "-tlnp"],
capture_output=True, capture_output=True,
text=True, text=True,
check=False check=False,
) )
pids = [] pids = []
for line in result.stdout.split("\n"): for line in result.stdout.split("\n"):
@@ -49,30 +112,48 @@ def find_server_process(port: int) -> list[int]:
return [] return []
def kill_server(port: int) -> None: def describe_processes(pids: list[int]) -> str:
"""Kill processes using the specified port.""" """Human-readable description of a pid list (pid + command line)."""
pids = find_server_process(port) descriptions = []
for pid in pids: for pid in pids:
cmdline = ""
try:
with open(f"/proc/{pid}/cmdline", "rb") as fh:
raw = fh.read().replace(b"\x00", b" ").decode("utf-8", "replace")
cmdline = raw.strip()
except OSError:
pass
descriptions.append(f"pid {pid}{' (' + cmdline + ')' if cmdline else ''}")
return ", ".join(descriptions) if descriptions else "none"
def kill_pids(pids: list[int], what: str) -> None:
"""Send SIGTERM (then SIGKILL) to the given PIDs, only after reporting."""
for pid in pids:
print(f"Sent SIGTERM to {what} pid {pid}")
try: try:
os.kill(pid, signal.SIGTERM) os.kill(pid, signal.SIGTERM)
print(f"Sent SIGTERM to process {pid}")
except ProcessLookupError: except ProcessLookupError:
pass pass
# Wait for processes to terminate # Wait for processes to terminate
time.sleep(1) deadline = time.time() + 5
while time.time() < deadline:
if not any(process_alive(pid) for pid in pids):
break
time.sleep(0.2)
# Force kill if still running # Force kill if still running
pids = find_server_process(port)
for pid in pids: for pid in pids:
if process_alive(pid):
try: try:
os.kill(pid, signal.SIGKILL) os.kill(pid, signal.SIGKILL)
print(f"Sent SIGKILL to process {pid}") print(f"Sent SIGKILL to {what} pid {pid}")
except ProcessLookupError: except ProcessLookupError:
pass pass
def is_server_ready(port: int, timeout: float = 0.5) -> bool: def is_server_ready(port: int, timeout: float = 2.0) -> bool:
"""Check if server is accepting connections.""" """Check if server is accepting connections."""
try: try:
with socket.create_connection(("127.0.0.1", port), timeout=timeout): with socket.create_connection(("127.0.0.1", port), timeout=timeout):
@@ -84,9 +165,15 @@ def is_server_ready(port: int, timeout: float = 0.5) -> bool:
def wait_for_server(port: int, timeout: int = 30) -> bool: def wait_for_server(port: int, timeout: int = 30) -> bool:
"""Wait for server to become ready.""" """Wait for server to become ready."""
start = time.time() start = time.time()
last_report = 0.0
while time.time() - start < timeout: while time.time() - start < timeout:
if is_server_ready(port): if is_server_ready(port):
return True return True
# Report progress every ~5s so a slow boot is visible, not silent.
elapsed = time.time() - start
if elapsed - last_report >= 5:
print(f" ...still waiting ({int(elapsed)}s/{timeout}s)")
last_report = elapsed
time.sleep(0.5) time.sleep(0.5)
return False return False
@@ -99,23 +186,30 @@ def main() -> int:
"--port", "--port",
type=int, type=int,
default=8188, default=8188,
help="Server port (default: 8188)" help="Server port (default: 8188)",
) )
parser.add_argument( parser.add_argument(
"--restart", "--restart",
action="store_true", action="store_true",
help="Kill existing server before starting" help="Kill the E2E server previously managed by this script for the port "
"(tracked via pidfile) before starting; refuse to kill unrelated processes",
) )
parser.add_argument( parser.add_argument(
"--wait", "--wait",
action="store_true", action="store_true",
help="Wait for server to be ready before exiting" help="Wait for server to be ready before exiting",
) )
parser.add_argument( parser.add_argument(
"--timeout", "--timeout",
type=int, type=int,
default=30, default=30,
help="Timeout for waiting (default: 30)" help="Timeout for waiting (default: 30)",
)
parser.add_argument(
"--detach",
action="store_true",
help="Launch the server fully detached (setsid-style) so it survives shell "
"death. REQUIRED for E2E: a plain background process dies with the shell",
) )
args = parser.parse_args() args = parser.parse_args()
@@ -125,31 +219,105 @@ def main() -> int:
skill_dir = os.path.dirname(script_dir) skill_dir = os.path.dirname(script_dir)
project_root = os.path.dirname(os.path.dirname(os.path.dirname(skill_dir))) project_root = os.path.dirname(os.path.dirname(os.path.dirname(skill_dir)))
# Restart if requested managed_pids = read_managed_pids(args.port)
if args.restart:
print(f"Killing existing server on port {args.port}...")
kill_server(args.port)
time.sleep(1)
# Check if already running # Restart if requested: kill ONLY managed PIDs.
if is_server_ready(args.port): if args.restart:
print(f"Server already running on port {args.port}") alive_managed = [pid for pid in managed_pids if process_alive(pid)]
if alive_managed:
print(
f"Killing E2E server previously started by this script on port "
f"{args.port} ({describe_processes(alive_managed)})..."
)
kill_pids(alive_managed, "managed E2E server")
else:
print(
f"No live managed E2E server for port {args.port} "
f"(pidfile: {pidfile_path(args.port)})"
)
time.sleep(1)
# Refuse to kill anything the script did not manage.
remaining = find_server_process(args.port)
if remaining:
print(
f"ERROR: port {args.port} is still held by process(es) this script "
f"did not start: {describe_processes(remaining)}."
)
print(
"These may be unrelated (e.g. a live ComfyUI). The script will NOT "
"kill them. Pick a different --port, or stop them manually if you "
"are certain they are stale E2E servers."
)
return 2
clear_managed_pids(args.port)
# Port conflict check before starting: never blind-kill.
port_pids = find_server_process(args.port)
if port_pids:
alive_managed = [pid for pid in port_pids if pid in managed_pids]
unmanaged = [pid for pid in port_pids if pid not in managed_pids]
if alive_managed and not unmanaged:
print(
f"Server already running on port {args.port} "
f"({describe_processes(alive_managed)}, started by this script). "
f"Use --restart to recycle it."
)
return 0 return 0
print(
f"ERROR: port {args.port} is already in use by process(es): "
f"{describe_processes(port_pids)}."
)
print(
"This is likely an unrelated process (e.g. a live ComfyUI holding 8188). "
"The script will NOT kill it. Pick a free port with --port, e.g. 8199."
)
return 2
# Start server # Start server
print(f"Starting LoRa Manager standalone server on port {args.port}...") print(f"Starting LoRa Manager standalone server on port {args.port}...")
cmd = [sys.executable, "standalone.py", "--port", str(args.port)] cmd = [
sys.executable,
"standalone.py",
"--host",
"127.0.0.1",
"--port",
str(args.port),
]
# Start in background if args.detach:
# Fully detached launch: new session (setsid), no controlling terminal,
# stdin from /dev/null, stdout/stderr to a log file. Survives the shell.
log_dir = os.path.join(script_dir, "logs")
os.makedirs(log_dir, exist_ok=True)
log_path = os.path.join(log_dir, f"server-{args.port}.log")
with open(log_path, "ab") as log_fh:
process = subprocess.Popen(
cmd,
cwd=project_root,
stdin=subprocess.DEVNULL,
stdout=log_fh,
stderr=subprocess.STDOUT,
start_new_session=True,
close_fds=True,
)
print(f"Detached server process started with PID {process.pid} (setsid)")
print(f"Log: {log_path}")
else:
# Plain background process (legacy behavior): dies with the shell.
process = subprocess.Popen( process = subprocess.Popen(
cmd, cmd,
cwd=project_root, cwd=project_root,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
start_new_session=True start_new_session=True,
)
print(f"Server process started with PID {process.pid}")
print(
"NOTE: not detached — this process dies when the launching shell exits. "
"For E2E use --detach."
) )
print(f"Server process started with PID {process.pid}") write_managed_pids(args.port, [process.pid])
# Wait for ready if requested # Wait for ready if requested
if args.wait: if args.wait:
@@ -157,8 +325,7 @@ def main() -> int:
if wait_for_server(args.port, args.timeout): if wait_for_server(args.port, args.timeout):
print(f"Server ready at http://127.0.0.1:{args.port}/loras") print(f"Server ready at http://127.0.0.1:{args.port}/loras")
return 0 return 0
else: print(f"Timeout waiting for server on port {args.port}")
print(f"Timeout waiting for server")
return 1 return 1
print(f"Server starting at http://127.0.0.1:{args.port}/loras") print(f"Server starting at http://127.0.0.1:{args.port}/loras")
@@ -1,15 +1,20 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Wait for LoRa Manager server to become ready. Wait for LoRa Manager server to become ready.
Timeout is configurable via --timeout (default 30s); the script polls the port
until the server accepts connections or the timeout expires.
""" """
from __future__ import annotations
import argparse import argparse
import socket import socket
import sys import sys
import time import time
def is_server_ready(port: int, timeout: float = 0.5) -> bool: def is_server_ready(port: int, timeout: float = 2.0) -> bool:
"""Check if server is accepting connections.""" """Check if server is accepting connections."""
try: try:
with socket.create_connection(("127.0.0.1", port), timeout=timeout): with socket.create_connection(("127.0.0.1", port), timeout=timeout):
@@ -21,9 +26,15 @@ def is_server_ready(port: int, timeout: float = 0.5) -> bool:
def wait_for_server(port: int, timeout: int = 30) -> bool: def wait_for_server(port: int, timeout: int = 30) -> bool:
"""Wait for server to become ready.""" """Wait for server to become ready."""
start = time.time() start = time.time()
last_report = 0.0
while time.time() - start < timeout: while time.time() - start < timeout:
if is_server_ready(port): if is_server_ready(port):
return True return True
# Report progress every ~5s so a slow boot is visible, not silent.
elapsed = time.time() - start
if elapsed - last_report >= 5:
print(f" ...still waiting ({int(elapsed)}s/{timeout}s)")
last_report = elapsed
time.sleep(0.5) time.sleep(0.5)
return False return False
@@ -36,13 +47,13 @@ def main() -> int:
"--port", "--port",
type=int, type=int,
default=8188, default=8188,
help="Server port (default: 8188)" help="Server port (default: 8188)",
) )
parser.add_argument( parser.add_argument(
"--timeout", "--timeout",
type=int, type=int,
default=30, default=30,
help="Timeout in seconds (default: 30)" help="Timeout in seconds (default: 30)",
) )
args = parser.parse_args() args = parser.parse_args()
@@ -52,7 +63,6 @@ def main() -> int:
if wait_for_server(args.port, args.timeout): if wait_for_server(args.port, args.timeout):
print(f"Server ready at http://127.0.0.1:{args.port}/loras") print(f"Server ready at http://127.0.0.1:{args.port}/loras")
return 0 return 0
else:
print(f"Timeout: Server not ready after {args.timeout}s") print(f"Timeout: Server not ready after {args.timeout}s")
return 1 return 1
+1
View File
@@ -25,6 +25,7 @@ model_cache/
reasonix.toml reasonix.toml
.reasonix/ .reasonix/
.codegraph/ .codegraph/
.playwright-mcp/
# Vue widgets development cache (but keep build output) # Vue widgets development cache (but keep build output)
vue-widgets/node_modules/ vue-widgets/node_modules/
+202
View File
@@ -0,0 +1,202 @@
---
slug: undo-delete-staging
status: drafting
intent: clear
review_required: false
pending-action: write .omo/plans/undo-delete-staging.md
approach: "Option B: delayed physical deletion with Undo. Backend: same-volume rename to per-root staging dir (.lm-pending-delete/) [updated 2026-08: model staging moved to a SIBLING dir inside each deleted model's own folder — see 'Symlink fix (2026-08)' under Decisions] + manifest JSON (batch_id, expires_at, staged->original map) + purge (30s TTL timer + startup sweep + opportunistic) + undo-delete endpoint + settings toggle 'skip undo'. Small files (recipes: JSON+preview) copy to global staging under settings dir instead of rename. Frontend: extend toast system with action button + 30s countdown; delete flows (single model / recipe / bulk / duplicates) consume batch_id from delete response and show Undo toast; expired undo -> 'undo expired' toast. Plus confirm-modal friction (C-friction, NO type-to-confirm): delete button delay-activation 1.5s + modal shows file size 'will free X GB' + Cancel gets initial focus. i18n keys + sync_translation_keys.py."
---
# Draft: undo-delete-staging
## Components (topology ledger)
<!-- Lock the SHAPE before depth. One row per top-level component that can succeed or fail independently. -->
<!-- id | outcome (one line) | status: active|deferred | evidence path -->
- backend staging module (stage/purge/undo + manifest + per-volume dir resolution) | new module, active | pending exploration: model_lifecycle_service.py delete_model / delete_model_artifacts
- delete endpoints return batch_id (model/recipe/bulk/duplicates) | active | pending exploration: handlers + response shapes
- undo-delete HTTP endpoint + route registration | active | pending exploration: route registrar pattern
- purge scheduling (30s timer + startup sweep + opportunistic) | active | pending exploration: app on_startup hooks
- settings toggle "skip undo window" | active | pending exploration: settings service read pattern
- frontend toast extension (action button + countdown) | active | pending exploration: showToast impl
- frontend delete flows consume batch_id + Undo toast | active | pending exploration: call sites
- confirm-modal friction (delay-activate + size display + cancel focus) | active | pending exploration: modal focus behavior
- i18n keys + sync_translation_keys.py | active | known
## Open assumptions (announced defaults)
<!-- Record any default you adopt instead of asking, so the user can veto it at the gate. -->
<!-- assumption | adopted default | rationale | reversible? -->
- Undo window TTL = 30s | 30s balances space-freeing intent vs accident recovery | yes (constant)
- Staging dir name: `.lm-pending-delete/` under each model root; recipes: `{settings_dir}/.lm-pending-delete/` | hidden, same-volume [updated 2026-08: same-volume is now guaranteed by sibling staging inside the model's own folder, not by the root location], consistent | yes
- Staging failure falls back to existing hard delete | user intent is delete; staging is best-effort; hard delete likely fails identically under same conditions | yes
- Purge on startup uses expires_at (not purge-all) so a <30s restart with live tab can still undo | robust, matches client-side timer | yes
- Settings toggle label: "Delete permanently immediately (skip undo window)" | power users freeing space | yes
- C-friction: delete button enabled after 1.5s + modal shows freed size; NO type-to-confirm (user vetoed) | user explicitly rejected type-to-confirm | n/a
- Bulk/duplicates delete: one batch id for whole action, one undo restores all | simplest consistent semantics | yes
## Findings (cited - path:lines)
### Backend
- `delete_model_artifacts` (py/services/model_lifecycle_service.py:19-48) = physical delete via os.remove; patterns: main file + `{name}.metadata.json` + PREVIEW_EXTENSIONS (py/utils/constants.py:22-37). ALSO called by ModelScanner.bulk_delete_models (py/services/model_scanner.py:2221) - single swap point covers bulk models.
- `ModelLifecycleService.delete_model` (model_lifecycle_service.py:101-154): fetches `cached_entry` (111-116) - SNAPSHOT available for cache restore; after delete: cache.raw_data removal + resort + bump_cache_version (136-143), `_hash_index.remove_by_path` (145-146), `_sync_update_for_model` (148; update-service only, no recipe JSON rewrites - recipe refs are hash-based, re-resolve on restore), `_persist_current_cache` (150-152), returns `{"success": True, "deleted_files": [...]}` (154).
- Handler `delete_model` (py/routes/handlers/model_handlers.py:478-492): POST /api/lm/{prefix}/delete; response passthrough; `_broadcast_models_changed()` (57-74) after success; 400 `{"success":false,"error"}`; 500 plain text.
- Recipe delete: handler (recipe_handlers.py:1422-1438) DELETE /api/lm/recipe/{recipe_id} -> persistence_service.delete_recipe (py/services/recipes/persistence_service.py:193-209): os.remove(recipe_json_path) + os.remove(image_path) (204-206), recipe_scanner.remove_recipe (208), returns `{"success": true, "message": ...}`. PersistenceResult dataclass (20-25).
- Bulk models: POST /api/lm/{prefix}/bulk-delete (model_route_registrar.py:39) -> handler (model_handlers.py:974-994) -> lifecycle_service.bulk_delete_models (model_lifecycle_service.py:308-318) -> scanner.bulk_delete_models (model_scanner.py:2181-2269) which calls delete_model_artifacts per file (2221) + `_batch_update_cache_for_deleted_models` (2271-2335); response `{"success","status","total_deleted","total_attempted","cache_updated","results"}` (2254-2269).
- Bulk recipes: POST /api/lm/recipes/bulk-delete (recipe_route_registrar.py:50) -> handler (recipe_handlers.py:1554-1573) -> persistence_service.bulk_delete (persistence_service.py:439-482): per-id os.remove x2 (464-466), recipe_scanner.bulk_remove (472); response `{"success","deleted","failed","total_deleted","total_failed"}` (474-482).
- Duplicates: NO dedicated delete endpoints (find-only: GET /api/lm/{prefix}/find-duplicates model_route_registrar.py:59, GET /api/lm/recipes/find-duplicates recipe_route_registrar.py:49). Duplicate deletion reuses bulk-delete endpoints.
- Startup hooks: lora_manager.py:183-187 `app.on_startup.append(lambda app: cls._initialize_services())` (ComfyUI mode, app = PromptServer.instance.app at :78); standalone.py:370-374 same (StandaloneLoraManager.add_routes). Background tasks: `asyncio.create_task(name=...)` (lora_manager.py:224-239; recipe_handlers.py:793). Singleton+asyncio.Lock pattern: model_scanner.py:40-63.
- Settings: DEFAULT_SETTINGS (py/services/settings_manager.py:57-119), `get(key, default)` (1390-1392), get_settings_manager() (2215-2228), reset_settings_manager() (2231). Typed-bool getter example: get_skip_previously_downloaded_model_versions (1253-1262). Handlers: base_model_routes.py:70, base_recipe_routes.py:54.
- Model roots: ModelScanner.get_model_roots base NotImplementedError (model_scanner.py:1073-1075); impls lora_scanner.py:31-45, checkpoint_scanner.py:428-441, embedding_scanner.py:24-36. `_find_root_for_file(file_path)` (model_scanner.py:1108-1124) returns containing root - for per-root staging dir computation [updated 2026-08: staging no longer uses the containing root; batches are siblings inside the model's own folder]. Business-path rule (AGENTS.md): use os.path.abspath, never realpath, for staging/undo routing.
- Cache restore methods: ModelCache has raw_data + resort (conftest mocks: tests/conftest.py:144-154); ModelHashIndex.add_entry(sha256, file_path, autov3) (py/services/model_hash_index.py:16); RecipeScanner.add_recipe(recipe_data) (recipe_scanner.py:2136) -> recipe_cache.add_recipe (recipe_cache.py:64). No single-file incremental model rescan - use snapshot restore instead of rescan.
- Route registrar: model_route_registrar.py:177 add_route(method, path, handler), :180 add_prefixed_route - undo endpoint can be a non-prefixed route via add_route.
- Tests: tests/services/test_model_lifecycle_service.py (inline tmp_path files, per-test stub scanners ScannerForDelete/VersionAwareScanner etc); conftest MockScanner/MockCache/MockHashIndex (tests/conftest.py:134-212); integration fixtures tests/integration/conftest.py; lifecycle hook tests tests/routes/test_lora_manager_lifecycle.py:177-178, tests/standalone/test_standalone_server.py:83-84.
### Frontend
- 5 delete call sites:
a) Single model: static/js/utils/modalUtils.js confirmDelete (27-42) -> getModelApiClient().deleteModel(path); ignores return.
b) Recipe single: static/js/components/RecipeCard.js confirmDeleteRecipe (405-449) - RAW fetch DELETE /api/lm/recipe/{id}, checks only response.ok, showToast toast.recipes.deletedSuccessfully, state.virtualScroller.removeItemByFilePath.
c) Bulk: static/js/managers/BulkManager.js confirmBulkDelete (633-672) -> getActiveApiClient() (134-142) -> bulkDeleteModels(filePaths); reads result.cancelled/success/deleted_count/error.
d) Recipe duplicates: static/js/components/DuplicatesManager.js confirmDeleteDuplicates (457-494) - RAW fetch POST /api/lm/recipes/bulk-delete, reads data.success/data.total_deleted, exitDuplicateMode().
e) Model duplicates: static/js/components/ModelDuplicatesManager.js confirmDeleteDuplicates (710-776) - RAW fetch POST /api/lm/{type}/bulk-delete, reads data.total_deleted, then resetAndReload(true) + find-duplicates re-check.
Bonus: static/js/components/shared/ModelVersionsTab.js:1136-1144 client.deleteModel (ignores return).
- API clients: BaseModelApiClient.deleteModel (static/js/api/baseModelApi.js:184-216) returns true/false, shows its own toasts, does removeItemByFilePath inside; bulkDeleteModels (1591-1642) returns {success, deleted_count, failed_count, errors} or {success:false, cancelled:true}; RecipeSidebarApiClient.bulkDeleteModels (recipeApi.js:623-664) returns {success, deleted_count: total_deleted, ...}. Endpoint map apiConfig.js:56,64.
- Toast: showToast(key, params={}, type='info', fallback=null) (static/js/utils/uiHelpers.js:136-193) - textContent only, NO action/button support; durations 2000/5000ms; CSS static/css/components/toast.css (.toast flex gap:12px - button can be added). Closest action pattern: bannerService.registerBanner actions array + onRegister (static/js/managers/BannerService.js; used uiHelpers.js:18-57).
- i18n: locales/en.json delete keys (1303-1314 bulkDelete, 1945-1948 recipes, 1987-1991 models, 2124-2130 duplicates, 2166-2170 toast.api); t()/interpolate (static/js/i18n/index.js:193-248); translate wrapper (utils/i18nHelpers.js:13-23); sync script scripts/sync_translation_keys.py (en reference, [TODO: Translate] placeholders).
- Refresh after undo: recipes -> window.recipeManager.loadRecipes(true) (recipes.js:359; used by FilterManager.js:752 etc) or refreshRecipes (recipeApi.js:308); models -> resetAndReload(true) from modelApiFactory (used by ModelDuplicatesManager.js:740).
- Size for modal: card.dataset.file_size (ModelCard.js:467), formatFileSize (ModelModal.js:615).
- Tests: tests/frontend/utils/uiHelpers.dom.test.js (toast), api/recipeApi.bulk.test.js, components/duplicatesManager.test.js, components/modelDuplicatesManager.test.js, pages/*Page.test.js, i18n tests tests/i18n/test_i18n.py.
## Decisions (with rationale)
1. Same-volume rename staging for model files (atomic, no copy cost for multi-GB files); cross-volume rename forbidden. [CORRECTED 2026-08: "same-volume because under the containing root" was only true for plain directories — nested symlinked subdirs could cross volumes. Superseded by sibling staging: `.lm-pending-delete/<batch_id>/` inside the deleted model's own folder makes stage/undo same-device by construction; see "Symlink fix (2026-08)" below.]
2. Copy-to-global-staging for recipes (small files; avoids recipe JSON vs preview image cross-volume problem).
3. Manifest JSON files are the only state - no DB changes. Manifest includes model cached_entry snapshot for exact cache restore (no rescan needed).
4. Undo endpoint returns restored paths; expired batch -> 404-style error -> frontend 'undo expired' toast.
5. Skip-undo setting honored server-side (no batch_id in response -> no undo toast client-side).
6. Staging failure falls back to existing hard delete (best-effort undo, never blocks delete).
7. Undo window TTL = 30s constant (PENDING_DELETE_TTL_SECONDS); startup sweep uses expires_at (survives restart; browser-tab timer survives).
8. Purge triple-trigger: per-batch asyncio timer task + on_startup sweep + opportunistic purge at each stage/undo.
9. Frontend: new showActionToast (keep showToast signature untouched; extract shared createToastElement/appendToast internals); undo click -> shared handleUndoDelete(batchId, refreshFn); full list refresh after undo (recipes: window.recipeManager.loadRecipes(true); models: resetAndReload(true)).
10. C-friction wave (NO type-to-confirm - user vetoed): delete buttons delay-activate 1.5s after modal open, initial focus on Cancel, model delete modal gains "permanently deleted from disk" warning + file size display (card.dataset.file_size + formatFileSize).
11. Model cache restore on undo: append snapshot to cache.raw_data (dedupe by file_path) + resort + bump_cache_version + _persist_current_cache + _hash_index.add_entry + _broadcast_models_changed. Recipe restore: copy back files + recipe_scanner.add_recipe(recipe_data loaded from restored JSON).
### Symlink fix (2026-08)
Post-execution addendum (plan `.omo/plans/undo-delete-symlink-fix.md`, commits 5fd4946b / 0c00ee22):
12. Model staging moved from `<model_root>/.lm-pending-delete/<batch_id>/` to `<model_dir>/.lm-pending-delete/<batch_id>/` (sibling of the model artifacts, inside the deleted model's own folder). Stage/undo renames are same-device BY CONSTRUCTION — EXDEV is impossible even when the business path traverses nested symlinks to other volumes (the decision-1 "containing root" guarantee covered only plain directories). EXDEV remains possible only for cross-volume merges, which keep the batch_ids-array fallback. Accepted edge: deleting the model's whole FOLDER during the 30s window destroys that batch (undo returns 404). Batch discovery uses an in-memory registry (`_known_batch_dirs`) with a startup reconciliation scan (`purge_expired(scan_roots=True)`) covering restarts and crash leftovers. Recipe batches unchanged (copy-based settings-dir staging with the `_restore_file` EXDEV fallback).
## Scope IN
- Model single delete (model_handlers delete_model / model_lifecycle_service)
- Recipe delete (recipe_handlers delete_recipe / persistence_service)
- Bulk delete (models scanner + recipes persistence) + duplicates (reuse bulk endpoints)
- Undo endpoint POST /api/lm/undo-delete (models + recipes, one batch space)
- Purge: timer + startup sweep + opportunistic
- Settings toggle delete_undo_enabled + settings page checkbox
- Frontend: showActionToast + all 5 delete flows + shared undo handler
- C-friction modal changes (delay-activate + cancel focus + warning copy + size display)
- i18n keys + sync_translation_keys.py
- Backend + frontend tests
## Scope OUT (Must NOT have)
- NO type-to-confirm / hold-to-confirm friction (user vetoed)
- NO OS trash integration (send2trash) in this iteration
- NO persistent recycle-bin UI (no trash browsing page)
- NO changes to exclude/unexclude flow
- NO DB migrations
- NO new dependencies (no send2trash)
- NO changes to download flows
- NO recipe-JSON rewriting on model undo (hash-based refs re-resolve themselves)
## Open questions
None - all implementation details resolved by exploration. Design decisions settled in conversation (B+C, no type-to-confirm).
## Approval gate
status: approved
<!-- Approach approved -> rerun scaffold without --draft-only, run Metis gap analysis, APPEND todo batches, fill TL;DR last, run structural self-check, then Phase 4 handoff. -->
## Review round state (ulw-plan-review-round-state-contract)
```json
{
"transition": "replace",
"phase": "review_round_initialized",
"applies_when": ["retry_after_plan_change"],
"atomic": true,
"review_required": true,
"plan_path": ".omo/plans/undo-delete-staging.md",
"plan_sha256": "8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc",
"review_round_id": "rr-undo-del-20260811-006",
"round_status": "active",
"pending-action": "review .omo/plans/undo-delete-staging.md",
"review": {
"momus": { "status": "pending", "workspace_root": "/mnt/data/reinstall-backup-2026-04-12/data/workspace/ComfyUI/custom_nodes/ComfyUI-Lora-Manager", "runtime_home": null, "target": ".omo/plans/undo-delete-staging.md", "round_id": "rr-undo-del-20260811-006", "plan_sha256": "8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc", "launch_id": null, "session": null, "result": null },
"independent": { "status": "pending", "workspace_root": "/mnt/data/reinstall-backup-2026-04-12/data/workspace/ComfyUI/custom_nodes/ComfyUI-Lora-Manager", "runtime_home": null, "target": ".omo/plans/undo-delete-staging.md", "round_id": "rr-undo-del-20260811-006", "plan_sha256": "8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc", "launch_id": null, "session": null, "result": null }
}
}
```
## Review results + fix/retry ledger
### Round 1 (rr-undo-del-20260811-001, plan sha256 6c52bf99...)
- momus: APPROVE (non-blocking notes: todo1+7 duplicate DEFAULT_SETTINGS key -> fixed todo 7 to verify-only; "batch_ids" plural in todos 8/9 acceptance -> fixed; purge OSError note -> folded into todo 1 purge semantics)
- independent (oracle): CHANGES_REQUESTED
- BLOCKING S1: scanner walks would index .lm-pending-delete staged files as ghost entries -> fixed: todo 1 now mandates scanner walk exclusion at model_scanner.py:706/:867/:1404/_process_model_file + acceptance (o) scanner-visibility test
- BLOCKING S2: manifest lacks model_type, undo could restore into wrong cache/hash index -> fixed: manifest now carries model_type + todo 5 resolves per-type scanner via registrar pattern + acceptance (b) checkpoint-batch test
- S3 merged-batch expires_at re-anchor -> fixed: merge_batches re-anchors now+TTL in todo 1 + todo 3/4 assertions
- S4 manifest-less dir policy -> fixed: quarantine to <batch_id>.orphaned, never delete (todo 1 + acceptance g)
- S5 partial-undo retry semantics -> fixed: per-entry restored flag write-through + retry test (acceptance e)
- S6 purge locked-file failure semantics -> fixed: skip file, keep batch, never rmtree past errors (todo 1 + acceptance i)
- T8 undo-after-restart test -> fixed: todo 5 acceptance (f)
- T9 recipe undo -> re-delete test -> fixed: todo 5 acceptance (h)
- T7 rescan-stale-entry test -> fixed: todo 5 acceptance (g)
- Route registration pinned to shared routes class per mode (NOT per-model-type registrar which registers 3x) -> fixed: todo 5 now creates py/routes/pending_delete_routes.py registered once in lora_manager.py:170-172 + standalone.py:356-358 + duplicate-route test (e)
- Version-index staleness on single-delete undo -> fixed: todo 5 follows bulk cache-update pattern incl. rebuild_version_index (model_scanner.py:2324)
- Cancelled-bulk batch_id frontend handling -> fixed: todo 9 shows action toast on cancelled+staged-subset
- Single-instance assumption -> added to Scope OUT
- Occupied-refusal loss UX -> accepted-intent documented in success criteria + modal copy
### Round 2 (rr-undo-del-20260811-002, plan sha256 f3d52235...)
- momus: APPROVE (all 12 round-1 fixes verified present; zero dead references; non-blocking nits only)
- independent (oracle): CHANGES_REQUESTED
- BLOCK-1: merge_batches file-movement semantics unspecified (silent data-loss vector) -> fixed: todo 1 now specifies move-into-winner-dir + entry re-point + loser-dirs-removed-only-when-empty + abort-on-move-failure (all batches intact) + merge inside service lock + acceptance (k) file-survival assertions + acceptance (l) merge-failure abort test
- BLOCK-2: same-file parallel edits within waves (todo 5 vs 6 on lora_manager.py; todo 8 vs 9 on baseModelApi.js) -> fixed: waves/matrix now serialize 5->6 and 8->9 with explicit reasons; matrix updated
- Recommended: checkpoint_scanner.py:331 exclusion -> fixed (todo 1 + acceptance p); S5 pre-check skips restored:true entries -> fixed (todo 1); _tags_count restore on undo -> fixed (todo 5 + acceptance j); undo-blind flows documented (ModelVersionsTab + misc_handlers:2456) -> fixed (todo 8 note + Scope OUT); merge-failure no-merge fallback contract (batch_ids array) -> fixed (todos 3/4/9)
### Round 3 (rr-undo-del-20260811-003, plan sha256 8f2dfd46...)
- momus: APPROVE (all round-2 fixes verified present + spot-checked refs; no new contradictions)
- independent (oracle): CHANGES_REQUESTED
- BLOCKING A: merged batches never timer-purged after re-anchor (winner's original timer no-ops at old expiry; no fresh timer for re-anchored expiry; idle server -> merged batch lingers, violating "30s purge" success criterion; affects EVERY bulk delete) -> fixed: todo 1 merge_batches now ARMS A FRESH PURGE TIMER for the winner with re-anchored expiry + acceptance (q) fresh-timer test + purge_expired must enumerate ALL scanner types' roots (explicit in todo 1)
- BLOCKING B: dependency matrix contradicted same-file policy for todos 8/9<->11 (5 shared files) and 12<->11 -> fixed: todo 11 now "Blocked by: 8, 9 (same files...)"; todo 12 blocked by 11 (sync after 11); wave text updated (11, then 12 AFTER 11); "Can parallelize with" columns corrected
- BLOCKING C: frontend batch_ids sequential-undo fallback has NO test + merge->undo loser-restore + merge->purge assertions missing -> fixed: todo 9 acceptance now tests the batch_ids fallback path; todo 1 acceptance now has (k2)/(k3)
- Notes folded: sub-second toast-tail expiry race accepted; EXDEV fallback = NORMAL path for cross-volume bulks [annotated 2026-08: after the sibling-staging fix, EXDEV can only arise during cross-volume MERGES, never during single stage/undo renames]
### Round 4 (rr-undo-del-20260811-004, plan sha256 179e7ff7...)
- momus: APPROVE (round-3 fixes verified; one non-blocking nit: todo 11 inline "Blocked by: —" stale -> fixed to "8, 9")
- independent (oracle): CHANGES_REQUESTED
- BLOCKING GAP-1 (NEW, introduced by round-3 fix): todo 8 handleUndoDelete always-refresh/always-toast contract contradicted todo 9's sequential loop "exactly ONE final refresh" -> fixed: handleUndoDelete(batchId, refreshFn, {showToast, refresh}) suppression options; todo 9 loop uses suppressed calls + one final refresh/toast; acceptance extended (loop failure mid-way -> stop + error toast + no final refresh; 404 body discrimination expired vs occupied)
- BLOCKING GAP-2: no cross-type purge enumeration test -> fixed: todo 1 acceptance (r) purges expired batches across lora root + checkpoint root + recipe staging dir in one call
- Non-blocking folded: GAP-3 404-copy discrimination -> fixed in todo 8 (d); GAP-4 merge partial-failure rollback direction (move back + restore manifests, extended (l) asserts sequential constituent undo still restores everything) -> fixed in todo 1; GAP-5 post-restart timer-loss residual gap documented -> fixed in todo 6; GAP-6 usage_stats.py:424 walk added to exclusion mandate + todo 5 acceptance (k) embeddings undo test
### Round 5 (rr-undo-del-20260811-005, plan sha256 dfaa39ea...)
- momus: APPROVE (all round-4 fixes verified; no new contradictions)
- independent (oracle): CHANGES_REQUESTED
- BLOCK-1: lock-ordering deadlock ambiguity (asyncio.Lock not re-entrant: opportunistic purge_expired called while stage/undo hold the lock would deadlock on first use) -> fixed: todo 1 now has explicit LOCK HIERARCHY (lock acquired ONLY by stage/merge/undo/purge_batch; purge_expired is lock-free and must be called BEFORE lock acquisition); todo 6 (c) updated with the same rule + acceptance (u) lock-no-deadlock test
- BLOCK-2: purge edge semantics unspecified -> fixed: purge_batch treats missing staged files (partially-restored batches) as already-purged (FileNotFoundError silent no-op); sweep skips `.orphaned`-suffixed dirs (quarantine is terminal); acceptance (s) partially-restored purge + (t) quarantine-terminal tests
- Non-blocking folded: todo 2/3 test-file collision -> todo 3's bulk tests moved to tests/services/test_model_scanner.py; todo 9 (d) DuplicatesManager refreshFn stated explicitly (recipes loadRecipes / models resetAndReload); modal-copy + bulk-count trade-offs acknowledged in success criteria; acceptance (r) extended with embeddings root
### Round 6 (rr-undo-del-20260811-006, plan sha256 8cf7c9be...)
- momus: APPROVE (all round-5 fixes verified; no new contradictions; references verified)
- independent (oracle): APPROVE — no blocking issues; all round-5 items fixed with working, tested solutions; no new race/data-loss/consistency defects
- Deferred optional improvements (non-blocking, recorded for executor awareness; plan file left untouched to preserve the approved digest):
1. Tag-count asymmetry: single delete_model never decrements _tags_count (lifecycle 101-154), bulk does (scanner 2297-2303); undo re-increment is exact for bulk, over-counts for single until rescan (cosmetic, self-healing). Optional fix riding in todo 2: decrement tags in the single-delete path to mirror bulk.
2. Todo 5 factual nit: ModelCache.resort() already rebuilds the version index — explicit rebuild in undo is belt-and-braces, no action needed.
3. Todo 8 premise nit: ModelVersionsTab call ignores deleteModel's return entirely — nothing breaks, no adaptation needed.
4. Todo 3's pytest command includes test_model_lifecycle_service.py which todo 2 edits in the same wave — run that file's tests after todo 2 lands.
5. merge_batches with a missing/quarantined constituent id: any sane fallback (abort -> batch_ids, or skip missing) acceptable — files stay staged either way.
## Review lifecycle
- rounds: 6 (rr-undo-del-20260811-001..006); final round both lanes APPROVE
- final live-plan validation: sha256 = 8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc — MATCHES approved round-6 digest
- status: APPROVED — ready for execution handoff ($start-work undo-delete-staging)
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -31,7 +31,7 @@ COVERAGE_FILE=coverage/backend/.coverage pytest \
--cov-report=xml:coverage/backend/coverage.xml --cov-report=xml:coverage/backend/coverage.xml
``` ```
### Frontend Development (Standalone Web UI) ### Frontend Development (LoRA Manager Web UI)
```bash ```bash
npm install npm install
@@ -154,9 +154,9 @@ npm run test:coverage # Generate coverage report
## Frontend UI Architecture ## Frontend UI Architecture
### 1. Standalone Web UI ### 1. LoRA Manager Web UI
- Location: `./static/` and `./templates/` - Location: `./static/` and `./templates/`
- Tech: Vanilla JS + CSS, served by standalone server - Tech: Vanilla JS + CSS, served by the hosting server (ComfyUI app in plugin mode, `standalone.py` in standalone mode)
- Tests via npm in root directory - Tests via npm in root directory
### 2. ComfyUI Custom Node Widgets ### 2. ComfyUI Custom Node Widgets
+2 -7
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -3,6 +3,8 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM from .py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
from .py.nodes.checkpoint_loader import CheckpointLoaderLM from .py.nodes.checkpoint_loader import CheckpointLoaderLM
from .py.nodes.unet_loader import UNETLoaderLM from .py.nodes.unet_loader import UNETLoaderLM
from .py.nodes.random_checkpoint_loader import RandomCheckpointLoaderLM
from .py.nodes.random_unet_loader import RandomUNETLoaderLM
from .py.nodes.trigger_word_toggle import TriggerWordToggleLM from .py.nodes.trigger_word_toggle import TriggerWordToggleLM
from .py.nodes.prompt import PromptLM from .py.nodes.prompt import PromptLM
from .py.nodes.text import TextLM from .py.nodes.text import TextLM
@@ -40,6 +42,12 @@ except (
"py.nodes.checkpoint_loader" "py.nodes.checkpoint_loader"
).CheckpointLoaderLM ).CheckpointLoaderLM
UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM
RandomCheckpointLoaderLM = importlib.import_module(
"py.nodes.random_checkpoint_loader"
).RandomCheckpointLoaderLM
RandomUNETLoaderLM = importlib.import_module(
"py.nodes.random_unet_loader"
).RandomUNETLoaderLM
TriggerWordToggleLM = importlib.import_module( TriggerWordToggleLM = importlib.import_module(
"py.nodes.trigger_word_toggle" "py.nodes.trigger_word_toggle"
).TriggerWordToggleLM ).TriggerWordToggleLM
@@ -79,6 +87,8 @@ NODE_CLASS_MAPPINGS = {
LoraTextLoaderLM.NAME: LoraTextLoaderLM, LoraTextLoaderLM.NAME: LoraTextLoaderLM,
CheckpointLoaderLM.NAME: CheckpointLoaderLM, CheckpointLoaderLM.NAME: CheckpointLoaderLM,
UNETLoaderLM.NAME: UNETLoaderLM, UNETLoaderLM.NAME: UNETLoaderLM,
RandomCheckpointLoaderLM.NAME: RandomCheckpointLoaderLM,
RandomUNETLoaderLM.NAME: RandomUNETLoaderLM,
TriggerWordToggleLM.NAME: TriggerWordToggleLM, TriggerWordToggleLM.NAME: TriggerWordToggleLM,
LoraStackerLM.NAME: LoraStackerLM, LoraStackerLM.NAME: LoraStackerLM,
LoraStackCombinerLM.NAME: LoraStackCombinerLM, LoraStackCombinerLM.NAME: LoraStackCombinerLM,
+327 -295
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -39,6 +39,7 @@ These fields are present in all model metadata files.
| `metadata_source` | string\|null | ❌ No | ✅ Yes | Last provider that supplied metadata (see below) | | `metadata_source` | string\|null | ❌ No | ✅ Yes | Last provider that supplied metadata (see below) |
| `last_checked_at` | float | ❌ No (default: `0`) | ✅ Yes | Unix timestamp of last metadata check | | `last_checked_at` | float | ❌ No (default: `0`) | ✅ Yes | Unix timestamp of last metadata check |
| `hash_status` | string | ❌ No (default: `"completed"`) | ✅ Yes | Hash calculation status: `"pending"`, `"calculating"`, `"completed"`, `"failed"` | | `hash_status` | string | ❌ No (default: `"completed"`) | ✅ Yes | Hash calculation status: `"pending"`, `"calculating"`, `"completed"`, `"failed"` |
| `autov3` | string\|null | ❌ No | ✅ Yes | CivitAI AutoV3 hash (first 12 chars, lowercase hex) sourced from the safetensors embedded metadata (`sshs_model_hash` / `modelspec.hash_sha256`). **Absent** = not yet checked (may be backfilled later); **`null`** = checked but unavailable (header has no recognized hash); **12-char hex string** = value |
--- ---
@@ -287,6 +288,7 @@ These fields are automatically synchronized with the filesystem:
- `preview_url` — Updated if preview file is moved/removed - `preview_url` — Updated if preview file is moved/removed
- `sha256` — Updated during hash calculation (when `hash_status="pending"`) - `sha256` — Updated during hash calculation (when `hash_status="pending"`)
- `hash_status` — Updated during hash calculation - `hash_status` — Updated during hash calculation
- `autov3` — Set when metadata is first created (from safetensors header); may be backfilled later for entries where it is absent
- `last_checked_at` — Timestamp of scan - `last_checked_at` — Timestamp of scan
- `metadata_source` — Set based on metadata provider - `metadata_source` — Set based on metadata provider
@@ -345,6 +347,7 @@ These fields can be edited by users at any time through the Lora Manager UI or b
| `metadata_source` | `null` | | `metadata_source` | `null` |
| `last_checked_at` | `0` | | `last_checked_at` | `0` |
| `hash_status` | `"completed"` | | `hash_status` | `"completed"` |
| `autov3` | absent (not checked) or `null` (checked, no value) |
| `usage_tips` | `"{}"` (LoRA only) | | `usage_tips` | `"{}"` (LoRA only) |
| `model_type` | `"checkpoint"` or `"embedding"` (not present in LoRA models) | | `model_type` | `"checkpoint"` or `"embedding"` (not present in LoRA models) |
@@ -354,6 +357,7 @@ These fields can be edited by users at any time through the Lora Manager UI or b
| Version | Date | Changes | | Version | Date | Changes |
|---------|------|---------| |---------|------|---------|
| 1.1 | 2026-08 | Added `autov3` field (CivitAI AutoV3 hash with three-state semantics) |
| 1.0 | 2026-03 | Initial schema documentation | | 1.0 | 2026-03 | Initial schema documentation |
--- ---
@@ -0,0 +1,206 @@
# Plan: Multi-File Downloads Within a Single CivitAI Model Version
**Issue:** [#1058 — Cannot download multiple file variants from the same model version](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1058)
**Status:** v2 — revised after adversarial review (backend correctness + frontend/tests)
**Scope:** CivitAI/CivArchive downloads of `lora`, `checkpoint`, `embedding` model types. HuggingFace downloads are out of scope (already per-file).
> v2 changelog: incorporated 18 review findings. Key changes vs v1:
> shared file resolver + `resolved_version_id` for the gate (R1); `file_params` normalization at API boundary (R2); D2 hash-matching rule fixed for empty-hash cases (R6/R7); D3 extended to re-point `version_index` on removal (R4); D4 replaced with a child table (R3); `delete_model_version` interaction documented (R5); `ModelVersionsTab` surface added to phase 2 (F6); phase-2 multi-file loop requires a reload-deferred download variant (F7); queue-retry `file_params=NULL` known issue recorded (R9); test-fixture gaps and revised estimates (F10).
---
## 1. Problem Statement
A CivitAI model version can contain multiple downloadable weight files (e.g. fp16/fp32, safetensors/ckpt, different sizes). LoRA Manager already has a working file-selection pipeline (frontend file dialog → `fileParams` → backend file matching), but downloaded state is tracked at the **model-version** level. After any single file of a version is downloaded:
1. The version is marked **In Library** and the file-selection entry point disappears.
2. The backend rejects further download attempts for that version.
There is no way to download the remaining files of the same version through LoRA Manager.
## 2. Current State (verified against code; all references confirmed by review)
### 2.1 Download gating — backend (`py/services/download_manager.py`)
`_execute_original_download` enforces two version-level gates:
- **Library gate, early** (lines 11571184, before metadata fetch, fires when `model_version_id` given) and **late** (lines 13501376, fires only when `model_version_id is None`): `scanner.check_model_version_exists(version_id)` across lora/checkpoint/embedding scanners → hard error `"Model version already exists in ... library"`.
- **History gate** (lines 12381279): when `skip_previously_downloaded_model_versions` setting is on, `_has_been_downloaded(model_type, version_id)` → silent skip. History DB primary key is `(model_type, version_id)` (`py/services/downloaded_version_history_service.py:61`).
File selection works: `file_params {id, type, format, size, fp}` is matched against `version_info.files` (lines 14981569), **but only under `if file_params and model_version_id:` (line 1499)** — with `model_id`-only requests the selection silently falls back to the primary file (15711619). `file_params` currently carries no file `name` or hash.
### 2.2 Downloaded-state surfacing — backend (`py/routes/handlers/model_handlers.py`)
`get_civitai_versions` (lines 21482188) sets per-version `existsLocally` via `cache.version_index.get(version_id)` (plus a single `localPath` from that entry) and `hasBeenDownloaded` via the history service. No per-file granularity.
### 2.3 Frontend blockers (`static/js/managers/DownloadManager.js`)
Three independent gates prevent re-entering the file dialog:
1. **Line 598:** file-select badge rendered only when `modelFiles.length > 1 && !existsLocally`.
2. **Lines 666681 (`updateNextButtonState`):** Next button disabled with "Already in Library" when `currentVersion.existsLocally`.
3. **Lines 784787 (`proceedToLocation`):** toast + abort when `currentVersion.existsLocally`.
The badge path (`confirmFileSelection` lines 737759 → `proceedToLocationContent``startDownload` single mode → `executeDownloadWithProgress` → POST `file_params`, `static/js/api/baseModelApi.js:12361250`) has **zero** `existsLocally` guards (all 12 occurrences enumerated; none on this path; `import/DownloadManager.js` has none either). The `.exists-locally` CSS class is purely visual (`download-modal.css:496499`). **Making the badge visible again is sufficient to unlock the flow** for phase 1.
Post-download refresh is clean: the modal closes and `resetAndReload(true)` performs a full library refetch (`DownloadManager.js:1063`); dialog reopen resets state and refetches versions with no client-side cache. No same-session staleness.
### 2.4 Local identity of the downloaded file
`LoraMetadata/CheckpointMetadata/EmbeddingMetadata.from_civitai_info(version_info, file_info, ...)` (`py/utils/models.py:245369`) persists:
- `sha256` = `file_info.hashes.SHA256` (lowercased, defaults to `""`) — a stable per-file identity;
- `civitai` = the full `version_info` payload (including the `files` list).
Metadata refresh (`metadata_sync_service.py:104105`) replaces the `civitai` blob wholesale but never overwrites top-level `sha256`; `verify_duplicate_hashes` (481526) corrects it to the on-disk hash. Top-level-sha256 matching is refresh-robust.
**Caveats (review R6/R7):**
- SHA256 is not guaranteed: CivArchive's transform only sets `hashes` when source data carries it (`civarchive_client.py:185189`); `from_civitai_info` defaults to `""`.
- Name fallback is unreliable exactly when it matters: local `file_name` is extension-less (`models.py:264`) and `generate_unique_filename` rewrites it with a hash suffix on conflict (`download_manager.py:11251136`); checkpoints with `hash_status='pending'` keep empty sha256 until on-demand hashing (`model_scanner.py:12321240`).
### 2.5 Version index collision (pre-existing hazard)
`ModelCache.version_index` is single-valued (`model_cache.py:133`: `version_index[version_id] = item`). Two files of the same version in the library → second entry overwrites the first; `remove_from_version_index` (lines 151181) drops the whole version key when the indexed entry is removed, even if a sibling file remains. ~10 read sites depend on this index (48 grep touch points total; readers include `recipe_scanner.py:26822726`, `recipe_format.py:3740`, `misc_handlers.py:24402444`, `model_handlers.py`, `model_scanner.check_model_version_exists:2444`).
Review correction (F3): bulk paths `remove_models` (`model_scanner.py:2376`) and `update_single_model_cache` (`:1689`) call `rebuild_version_index()` right after, so a sibling re-enters the index in those flows — the hazard is narrower than v1 stated, but direct `remove_from_version_index` callers (e.g. `model_scanner.py:1018`) still drop the key, and the user-visible artifact in phase 1 is real: `localPath` in the dialog flips to whichever file was indexed last.
### 2.6 Entry points that send / don't send `file_params` (fully enumerated by review)
**Send `file_params` (user-initiated dialog flows only):** `DownloadManager.js:16111639` (single mode). API surface accepting arbitrary JSON `file_params`: GET `/api/lm/download-model-get` (`model_handlers.py:16341686`), POST `/api/lm/downloads/queue/add` (`model_handlers.py:17991832`).
**Never send `file_params` (keep version-level semantics):** batch download (`DownloadManager.js:17561766`; batch also filters out in-library versions at `:1648`), `downloadVersionWithDefaults` (`:18101830`), recipe import (`import/DownloadManager.js:269276`), bulk missing-LoRA (`BulkMissingLoraDownloadManager.js:292299`), `RecipeModal.js:17281736`, `ModelVersionsTab.js:1427`. `web/comfyui/` and `vue-widgets/src` contain **no** download triggers at all (grep-verified). `py/services/use_cases/` has only `download_model_use_case.py` (pass-through).
### 2.7 Paths that do NOT need changes (verified)
- **aria2 pause/resume** (`_resume_restored_aria2_download`, line 754+): resumes from persisted `resume_context`; never re-runs existence gates.
- **`download_coordinator.py:90`**: pure pass-through of `file_params`.
- **Update checker / plugin self-update** (`update_routes.py:496501`): only closes the history DB handle.
- **History delete semantics**: `mark_as_deleted` sets `is_deleted_override=1` and `has_been_downloaded` then returns False (`downloaded_version_history_service.py:276`) — LM-initiated deletes already reset the history skip.
### 2.8 Related pre-existing issues (record, not necessarily fix)
- **Queue retry drops file selection** (R9): `download_queue_service.retry_from_history` / `retry_all_failed` re-queue with `file_params=NULL` (`download_queue_service.py:705, 758`) although the queue table has a `file_params` column (`:43`) — a retried non-primary download silently reverts to the primary file. Fix alongside phase 1 (small: persist and reuse the column).
- **`delete_model_version`** (`misc_handlers.py:24102487`): resolves the file via the single-valued `version_index` (24402444), deletes only that one file, and `mark_as_deleted` flags the **entire version** as deleted in history (2479) even when a sibling file remains in the library. See phase 2 item 6.1.5.
## 3. Goals / Non-Goals
**Goals**
- G1: A user can download any not-yet-downloaded file of a version already partially in the library (issue repro steps 68).
- G2: True duplicates stay blocked: downloading the *same* file of the same version twice is rejected.
- G3: Per-file downloaded state visible in the file dialog; multiple files selectable and downloadable in one pass.
- G4: No regression for version-level semantics relied on by batch download, recipe missing-LoRA detection, and `skip_previously_downloaded_model_versions`.
**Non-Goals**
- No change to recipe `inLibrary` semantics ("any file of the version present" remains sufficient).
- No change to the update-checker (version-level comparison).
- No primary-key rebuild of the history database.
- HuggingFace download flow untouched.
## 4. Design Decisions
- **D1 — Explicit file selection bypasses the history gate, version-level gates stay for everyone else.** The history skip exists to dedupe automated flows. A user explicitly picking a file is unambiguous intent; the file-level library gate (G2) still prevents real duplicates. **Guard conditions use normalized truthiness** (see D1a). All confirmed `file_params` senders are user-initiated dialog flows (2.6), and LM-initiated deletes already reset history (2.7), so the bypass only affects "downloaded but not LM-deleted" versions with the setting on — intended.
- **D1a — `file_params` normalization at the boundary (R2).** `download-model-get` and `downloads/queue/add` accept arbitrary JSON; `{}` is `not None` but falsy and would bypass gates while downloading the primary file. Normalize `file_params = file_params or None` in the coordinator/handlers, and treat the bypass as active only when a target file id is resolvable.
- **D2 — File identity matching rule (R6/R7):** hash-compare **only when both sides are non-empty** (lowercase SHA256 equality); name-compare when either side is empty. Never let `"" == ""` match. Name fallback caveats from 2.4 apply (renamed files, pending checkpoint hashes) — acceptable residual risk, worst case is a blocked re-download the user can retry after hashing completes.
- **D3 — Cache indexes: additive multi-index + removal re-pointing (R4).** Add `version_files_index: Dict[int, List[dict]]` maintained alongside `version_index` by the same add/remove/rebuild methods; existing readers of `version_index` untouched. Additionally fix `remove_from_version_index`: when the popped entry has a surviving sibling (per the multi-index), re-point `version_index[version_id]` to the sibling instead of dropping the key; same for the `model_id_index` descriptor. This closes the 2.5 hazard for existing readers (`check_model_version_exists`, `existsLocally`, recipe matching) without restructuring anything.
- **D4 — Per-file history via a child table (R3).** v1's additive-column approach is structurally impossible on a `(model_type, version_id)` PK (`ON CONFLICT DO UPDATE` would keep only the last file). Instead add `downloaded_version_files(model_type, version_id, file_id, file_name, downloaded_at, PRIMARY KEY(model_type, version_id, file_id))` — additive, no PK rebuild, honors the Non-Goal. Existing version-level table and queries unchanged. New per-file queries are opt-in. `_initialize_schema` uses `CREATE TABLE IF NOT EXISTS`, so the new table is created for existing DBs without any ALTER.
- **D5 — UI flow reuse, with an extracted inner download function for multi-file (F7).** Phase 1 unlocks the existing badge → file dialog → location → download pipeline. Phase 2 upgrades the dialog to multi-select; iterating `executeDownloadWithProgress` as-is would produce N full library reloads, N toasts, and competing failure-summary modals — so phase 2 extracts a reload-deferred, failure-aggregating inner variant and runs one reload + one summary at the end.
## 5. Implementation — Phase 1 (fix the issue; independently shippable)
### 5.1 Backend — `py/services/download_manager.py`
1. **Normalize `file_params`** at the boundary (D1a): `download_coordinator.schedule_download` and the two API handlers (`model_handlers.py:16491666`, `18101832`) apply `file_params = file_params or None`.
2. **Extract a shared file resolver** (R1): pull the matching logic at 14981569 into `_resolve_target_file(version_info, file_params) -> Optional[dict]`, used by **both** the new gate and the download-selection path. The selection path's condition (line 1499) switches from `model_version_id` to `resolved_version_id` (already computed at 12301236 from `version_info.id`), so gate and download always agree on the target file — including the `model_id`-only case.
3. **New helper** `_find_local_file_entry(version_id, target_file) -> Optional[dict]`: iterate the three scanners' cached `raw_data` (NOT `version_index` — single-valued); candidates = entries whose `civitai.id` normalizes to `version_id`; match per D2.
4. **Gate restructure in `_execute_original_download`**:
- Early scanner gate (11571184): add `file_params is None` guard; with normalized `file_params`, defer (file identity not resolvable before metadata fetch).
- After `version_info` fetch + `resolved_version_id` (~1229): when `file_params` present, resolve target file via the shared resolver; unresolvable → hard error "No matching file" (fail closed, prevents empty-dict bypass). Resolvable → `_find_local_file_entry`; hit → same hard error shape as today with the file name in the message.
- History gate (12381279): add `file_params is None` (D1). Base-model skip (12811324) unchanged — still applies.
- Late gate (13501376): add `file_params is None` guard (F2) — the post-fetch file-level check above already covers this case.
- Nothing between the early gate and the post-fetch point assumes the version is absent (review task 6: only provider selection + metadata fetch; no DB writes; `_persist_aria2_state` runs only when actually downloading at 1659).
5. **Queue retry fix** (2.8, small): persist `file_params` into the queue table on enqueue and reuse it in `retry_from_history` / `retry_all_failed`.
6. Logging: `[download]` lines for file-level allow/block, consistent with existing style.
**Estimated:** ~150220 LOC + resolver extraction.
### 5.2 Frontend — `static/js/managers/DownloadManager.js`
1. Line 598: drop `&& !existsLocally` from the badge condition (badge shows whenever `modelFiles.length > 1`).
2. `fileParams` construction (16111616): add `name: this.selectedFile.name`.
3. Surface the backend "file already in library" hard error as a toast instead of only the batch-summary modal (R10/F12 nit; reuse existing error message field).
4. No changes to `updateNextButtonState` / `proceedToLocation` in phase 1; no template or CSS changes.
**Known phase-1 UX limitations (acknowledged, fixed in phase 2):** with all files downloaded the badge still renders and re-picking a downloaded file fails late (backend error after the location step); `localPath` may point at a sibling file; batch-preview "In Library" badge stays version-level and gives no hint of remaining files.
**Estimated:** ~1030 LOC (confirmed realistic by review).
### 5.3 Phase 1 tests
Backend — extend `tests/services/test_download_manager_basic.py` (1694 lines; all fixture patterns exist):
- **Fixture gaps to add (F10):** `DummyScanner.get_cached_data()`/`raw_data` stub (~10 lines); `hashes.SHA256` in the metadata-provider payload's `files`.
- Cases: same version + different SHA256 in library + `file_params` → proceeds; same SHA256 → hard error; `file_params=None` + version in library → hard error (unchanged); history-skip on + `file_params` → not skipped; without → skipped (unchanged); empty-dict `file_params` normalized → version-level behavior; `model_id`-only + `file_params` → gate and selection resolve the same file; legacy metadata (empty local sha256) matched by name; target file with empty SHA256 → name fallback, no `""==""` false positive.
- Queue retry: `file_params` survives retry.
- Assert proceed/abort via the existing `_execute_download` mock pattern.
Frontend (`tests/frontend/`): badge renders for multi-file version with `existsLocally=true` (pattern from `downloadManager.history.test.js`).
**Estimated:** ~150250 LOC (confirmed realistic).
## 6. Implementation — Phase 2 (per-file status + multi-select + index hardening)
### 6.1 Backend
1. **`py/services/model_cache.py`** (D3): add `version_files_index`; maintain in `add_to_version_index` / `remove_from_version_index` / `rebuild_version_index`; removal re-points `version_index[version_id]` (and the `model_id_index` descriptor) to a surviving sibling instead of dropping the key.
2. **`py/services/model_scanner.py`**: expose `get_files_for_version(version_id) -> List[dict]`.
3. **`py/routes/handlers/model_handlers.py` `get_civitai_versions`**: annotate each version with `downloadedFiles: [{fileId, fileName, filePath}]` via `version_files_index` + D2 matching against `version.files`.
4. **`py/services/downloaded_version_history_service.py`** (D4): new child table `downloaded_version_files`; `mark_downloaded` also upserts the child row when `file_id` known; `mark_as_deleted` clears the version's child rows only when no sibling remains in the library; new `get_downloaded_file_ids(model_type, version_id) -> set[int]`. `_record_downloaded_version_history` passes `file_info` through.
5. **`delete_model_version`** (`misc_handlers.py:24102487`, R5): resolve **all** local files of the version via `version_files_index`; delete all (current endpoint semantics are version-level) or — if kept per-file — only `mark_as_deleted` when no sibling remains. Decide at implementation time; minimum is documenting current behavior.
6. **`ModelVersionsTab` backend support**: none needed beyond item 3 (`downloadedFiles`); the tab consumes the same versions payload.
### 6.2 Frontend
1. **File dialog multi-select** — change surface (F8): option markup (`DownloadManager.js:712724`), the single-select click handler (`727734`), the `input[type="radio"]:checked` selector in `confirmFileSelection` (`738`); template `templates/components/modals/download_modal.html:4860` (confirm-button label only); CSS `download-modal.css` — checkbox variant of `.file-option-radio input` (595604) and a **new** `.file-option.disabled` style (does not exist). Files whose id ∈ `downloadedFiles` render disabled with an "In Library" tag.
2. **Mixed-type guard (F8):** multi-select is restricted to files sharing the same routing target (`_isDiffusionModel` is computed once from a single `selectedFile` at 798803; e.g. "Model" + "UNet" files route to different roots). Disallow mixed-type multi-select (simplest, predictable); single-file selection unchanged.
3. **Multi-file download loop (D5/F7):** extract from `executeDownloadWithProgress` a reload-deferred, no-toast inner function; iterate per selected file with per-file progress; one `resetAndReload(true)` + one aggregated success/failure summary at the end (reuse `showDownloadBatchSummary`).
4. **`updateNextButtonState` / `proceedToLocation`:** for multi-file versions, Next routes into the file dialog; hard block only when *every* weight file is downloaded.
5. **`ModelVersionsTab.js` (F6):** the Download action (`:576` hidden when `isInLibrary`) — for multi-file versions with remaining files, show it and route into the download modal's file dialog; keep hidden when all files present.
6. **Batch preview (F5):** `batch-preview-local-badge` (`:1320`) gains a "partially downloaded" hint for multi-file versions with remaining files.
7. New i18n keys (`modals.download.fileSelection.inLibrary`, `downloadSelected`, partial-download tooltip, etc.) → run `python scripts/sync_translation_keys.py`.
### 6.3 Phase 2 tests
- `model_cache` (`tests/services/test_model_cache.py` already covers add/remove at 4455): multi-valued index; sibling re-point on removal; rebuild.
- `get_civitai_versions`: `downloadedFiles` correctness (hash match, name fallback, no match, CivArchive no-hash payload).
- History service (`tests/services/test_downloaded_version_history_service.py` uses real SQLite on tmp_path): child-table creation on a legacy DB; per-file record/query; `mark_as_deleted` sibling semantics.
- Frontend: dialog checkbox rendering/disabled state and multi-file confirm — **greenfield behavior coverage** (F10: no existing test exercises `showFileSelectionStep`/`confirmFileSelection`; infra exists, patterns must be built).
## 7. Risks and Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| History-gate bypass (D1) causes unwanted re-downloads in automated flows | Large checkpoint files re-downloaded | Bypass only with normalized, resolvable `file_params` (D1a); all such senders are user-initiated dialog flows (2.6, verified); tests pin batch/recipe/bulk behavior. |
| Empty-hash matching edge cases (R6) | Duplicate download of the same file, or false block | D2 rule: hash only when both non-empty; name otherwise; never `""==""`. Residual risk documented (2.4). |
| Phase-1 late-failure UX (F12) | User picks a downloaded file, fails only after location step | Toast surfacing (5.2.3); phase 2 disables downloaded files up front. |
| Phase-2 index change corrupts existing behavior | Recipe matching, delete flows | Additive index + re-point only; `version_index` read semantics unchanged; `remove_models`/`update_single_model_cache` already rebuild (F3); tests. |
| `delete_model_version` marks whole version deleted while sibling remains (R5) | History wrongly suppresses re-download of the surviving sibling's version | Phase 2 item 6.1.5; documented until then. |
| History child-table migration failure on user installs | Service init crash | `CREATE TABLE IF NOT EXISTS` in `_initialize_schema`; failure degrades to version-level behavior (per-file queries return empty). |
| Batch-preview badge misleading for partial versions (F5) | Minor UX confusion | Acknowledged in phase 1; fixed in phase 2 item 6.2.6. |
| UI confusion: version shows "In Library" while files remain downloadable | Support burden | Phase 2: per-file disabled state + partial-download tooltip. |
| Hash-identical sibling files (repacked content) | Second file blocked | Acceptable: scanner hash dedup already collapses them. |
## 8. Rollout
1. **Commit 1**`fix(download): allow downloading additional files of an in-library model version (#1058)` → Phase 1 (5.15.3).
2. **Commit 2**`feat(download): per-file download status and multi-file selection (#1058)` → Phase 2 (6.16.3).
Phase 1 alone resolves the issue as reported; phase 2 can ship in a later release if review prefers smaller increments.
## 9. Effort Estimate (revised after review)
| Phase | Backend | Frontend | Tests | Risk |
|---|---|---|---|---|
| 1 | ~150220 LOC (+ queue-retry fix ~30) | ~1030 LOC | ~150250 LOC | Low |
| 2 | ~250350 LOC | ~250350 LOC (multi-file loop refactor + ModelVersionsTab + batch badge) | ~250350 LOC (dialog tests greenfield) | Medium |
+111 -6
View File
@@ -186,6 +186,16 @@
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.", "cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
"error": "Recipe-Reparatur fehlgeschlagen: {message}" "error": "Recipe-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}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "Ausgeschlossene Modelle verwalten" "label": "Ausgeschlossene Modelle verwalten"
}, },
@@ -449,6 +459,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)" "compact": "7 (1080p), 8 (2K), 10 (4K)"
}, },
"displayDensityWarning": "Warnung: Höhere Dichten können bei Systemen mit begrenzten Ressourcen zu Performance-Problemen führen.", "displayDensityWarning": "Warnung: Höhere Dichten können bei Systemen mit begrenzten Ressourcen zu Performance-Problemen führen.",
"recipesLayout": "Rezepte-Layout",
"recipesLayoutHelp": "Wählen Sie, wie Rezeptkarten angeordnet werden: ein einheitliches Raster oder ein Masonry-Layout (Pinterest-Stil), das das Seitenverhältnis jedes Bildes beibehält.",
"recipesLayoutOptions": {
"grid": "Raster",
"masonry": "Masonry"
},
"showFolderSidebar": "Ordner-Seitenleiste anzeigen", "showFolderSidebar": "Ordner-Seitenleiste anzeigen",
"showFolderSidebarHelp": "Blenden Sie die Ordner-Navigationsleiste auf den Modellseiten ein oder aus. Wenn deaktiviert, bleiben Seitenleiste und Hoverbereich verborgen.", "showFolderSidebarHelp": "Blenden Sie die Ordner-Navigationsleiste auf den Modellseiten ein oder aus. Wenn deaktiviert, bleiben Seitenleiste und Hoverbereich verborgen.",
"cardInfoDisplay": "Karten-Info-Anzeige", "cardInfoDisplay": "Karten-Info-Anzeige",
@@ -606,6 +622,10 @@
"label": "Früher Zugriff Updates ausblenden", "label": "Früher Zugriff Updates ausblenden",
"help": "Nur Early-Access-Updates" "help": "Nur Early-Access-Updates"
}, },
"hidePaidUpdates": {
"label": "Bezahlte Updates ausblenden",
"help": "Wenn aktiviert, zeigen Modelle mit nur bezahlten Updates kein 'Update verfügbar'-Badge an"
},
"licenseIcons": { "licenseIcons": {
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden", "useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
"useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design." "useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design."
@@ -678,6 +698,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "Benutzerdefiniert (OpenAI-kompatibel)" "custom": "Benutzerdefiniert (OpenAI-kompatibel)"
}, },
@@ -761,6 +782,7 @@
"copyAll": "Alle Syntax kopieren", "copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren", "refreshAll": "Alle Metadaten aktualisieren",
"repairMetadata": "Metadaten der Auswahl reparieren", "repairMetadata": "Metadaten der Auswahl reparieren",
"rematchMetadata": "Ausgewählte mit lokalen Modellen abgleichen",
"reimportMetadata": "Aus Quelle neu importieren", "reimportMetadata": "Aus Quelle neu importieren",
"checkUpdates": "Auswahl auf Updates prüfen", "checkUpdates": "Auswahl auf Updates prüfen",
"moveAll": "Alle in Ordner verschieben", "moveAll": "Alle in Ordner verschieben",
@@ -816,6 +838,7 @@
"setContentRating": "Inhaltsbewertung festlegen", "setContentRating": "Inhaltsbewertung festlegen",
"moveToFolder": "In Ordner verschieben", "moveToFolder": "In Ordner verschieben",
"repairMetadata": "Metadaten reparieren", "repairMetadata": "Metadaten reparieren",
"rematchMetadata": "Mit lokalen Modellen abgleichen",
"reimportMetadata": "Aus Quelle neu importieren", "reimportMetadata": "Aus Quelle neu importieren",
"excludeModel": "Modell ausschließen", "excludeModel": "Modell ausschließen",
"restoreModel": "Modell wiederherstellen", "restoreModel": "Modell wiederherstellen",
@@ -830,7 +853,8 @@
"recipes": { "recipes": {
"title": "LoRA-Rezepte", "title": "LoRA-Rezepte",
"actions": { "actions": {
"sendCheckpoint": "Send to ComfyUI" "sendCheckpoint": "Send to ComfyUI",
"sendRecipe": "Send to ComfyUI"
}, },
"controls": { "controls": {
"import": { "import": {
@@ -901,7 +925,9 @@
"dateAsc": "Älteste", "dateAsc": "Älteste",
"lorasCount": "LoRA-Anzahl", "lorasCount": "LoRA-Anzahl",
"lorasCountDesc": "Meiste", "lorasCountDesc": "Meiste",
"lorasCountAsc": "Wenigste" "lorasCountAsc": "Wenigste",
"opened": "Zuletzt geöffnet",
"openedDesc": "Zuletzt geöffnet"
}, },
"refresh": { "refresh": {
"title": "Rezeptliste aktualisieren", "title": "Rezeptliste aktualisieren",
@@ -912,12 +938,25 @@
"favorites": { "favorites": {
"title": "Nur Favoriten anzeigen", "title": "Nur Favoriten anzeigen",
"action": "Favoriten" "action": "Favoriten"
},
"layout": {
"title": "Rezepte-Layout",
"grid": "Raster-Layout",
"masonry": "Masonry-Layout (Pinterest-Stil, behält das Seitenverhältnis des Bildes bei)"
} }
}, },
"duplicates": { "duplicates": {
"found": "{count} Duplikat-Gruppen gefunden", "found": "{count} Duplikat-Gruppen gefunden",
"noGroups": "Keine Duplikat-Gruppen mit dem aktuellen Abgleichskriterium gefunden",
"keepLatest": "Neueste Versionen behalten", "keepLatest": "Neueste Versionen behalten",
"deleteSelected": "Ausgewählte löschen" "deleteSelected": "Ausgewählte löschen",
"includePromptLabel": "Prompt beim Abgleich berücksichtigen",
"basis": {
"loraCombo": "Abgeglichen nach: LoRA-Kombination",
"loraComboAndPrompt": "Abgeglichen nach: LoRA-Kombination + Prompt",
"hintLoraCombo": "Rezepte mit denselben LoRAs bei identischen Stärken werden gruppiert.",
"hintPromptIncluded": "Rezepte werden nur gruppiert, wenn sie dieselben LoRAs bei identischen Stärken UND denselben Prompt verwenden."
}
}, },
"contextMenu": { "contextMenu": {
"copyRecipe": { "copyRecipe": {
@@ -1205,11 +1244,13 @@
"downloaded": "Heruntergeladen", "downloaded": "Heruntergeladen",
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.", "downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
"alreadyInLibrary": "Bereits in Bibliothek", "alreadyInLibrary": "Bereits in Bibliothek",
"partiallyDownloaded": "Teilweise heruntergeladen",
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]", "autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
"fileSelection": { "fileSelection": {
"title": "Dateiformat auswählen", "title": "Dateiformat auswählen",
"files": "Dateien", "files": "Dateien",
"select": "Datei auswählen" "select": "Datei auswählen",
"inLibrary": "In Bibliothek"
}, },
"errors": { "errors": {
"invalidUrl": "Ungültiges Civitai URL-Format", "invalidUrl": "Ungültiges Civitai URL-Format",
@@ -1250,8 +1291,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "Gibt {size} frei",
"title": "Modell löschen", "title": "Modell löschen",
"message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?" "message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?",
"recoverableWarning": "Die Datei wird nach 20 Sekunden endgültig gelöscht, sofern Sie nicht rückgängig machen."
},
"deleteRecipe": {
"recoverableWarning": "Diese Aktion kann 20 Sekunden lang rückgängig gemacht werden."
}, },
"excludeModel": { "excludeModel": {
"title": "Modell ausschließen", "title": "Modell ausschließen",
@@ -1489,6 +1535,30 @@
"examples": "Beispiele werden geladen...", "examples": "Beispiele werden geladen...",
"versions": "Versionen werden geladen..." "versions": "Versionen werden geladen..."
}, },
"showcase": {
"hiddenBySfw": "{count} durch Nur-SFW-Einstellung ausgeblendet",
"showExamples": "Beispiele anzeigen",
"showCount": "Beispiele anzeigen ({count})",
"hideExamples": "Beispiele ausblenden",
"addExamples": "Beispiele hinzufügen",
"previousExample": "Vorheriges Beispiel",
"nextExample": "Nächstes Beispiel",
"noExamples": "Keine Beispielbilder verfügbar",
"addMoreExamples": "Weitere Beispiele hinzufügen",
"dragDrop": "Bilder oder Videos hierher ziehen & ablegen",
"or": "oder",
"selectFiles": "Dateien auswählen",
"supportedFormats": "Unterstützte Formate: jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "Dateien werden importiert...",
"noSupportedFiles": "Keine unterstützten Dateien ausgewählt. Bitte wählen Sie Bild- oder Videodateien aus.",
"allFiltered": "Alle Beispielbilder wurden aufgrund der NSFW-Inhaltseinstellungen herausgefiltert",
"sfwOnlyEnabled": "Ihre Einstellungen zeigen derzeit nur jugendfreie Inhalte an",
"changeInSettings": "Sie können dies in den Einstellungen ändern",
"nsfwMature": "Nicht jugendfreie Inhalte",
"nsfwR": "Inhalte ab 18 (R)",
"nsfwX": "Inhalte mit X-Einstufung",
"nsfwXxx": "Inhalte mit XXX-Einstufung"
},
"versions": { "versions": {
"heading": "Modellversionen", "heading": "Modellversionen",
"copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.", "copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.",
@@ -1516,6 +1586,8 @@
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version", "newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
"earlyAccess": "Früher Zugriff", "earlyAccess": "Früher Zugriff",
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich", "earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
"paid": "Bezahlt",
"paidTooltip": "Diese Version erfordert eine Zahlung zum Herunterladen",
"ignored": "Ignoriert", "ignored": "Ignoriert",
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert", "ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
"onSiteOnly": "Nur On-Site", "onSiteOnly": "Nur On-Site",
@@ -1524,7 +1596,9 @@
"actions": { "actions": {
"download": "Herunterladen", "download": "Herunterladen",
"downloadTooltip": "Diese Version herunterladen", "downloadTooltip": "Diese Version herunterladen",
"downloadRemainingTooltip": "Verbleibende Dateien dieser Version herunterladen",
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen", "downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
"downloadPaidTooltip": "Diese bezahlte Version von Civitai herunterladen",
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar", "downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
"delete": "Löschen", "delete": "Löschen",
"deleteTooltip": "Diese lokale Version löschen", "deleteTooltip": "Diese lokale Version löschen",
@@ -1581,6 +1655,21 @@
"downloadCsv": "CSV herunterladen", "downloadCsv": "CSV herunterladen",
"columnModelName": "Modellname", "columnModelName": "Modellname",
"columnError": "Fehler" "columnError": "Fehler"
},
"downloadBatchSummary": {
"title": "Zusammenfassung des Batch-Downloads",
"statSuccess": "Erfolgreich",
"statFailed": "Fehlgeschlagen",
"statTotal": "Gesamt",
"successMessage": "Alle {count} Modelle erfolgreich heruntergeladen",
"completedWithErrors": "Abgeschlossen, aber mit Fehlern",
"failed": "Download fehlgeschlagen",
"failedItems": "Fehlgeschlagene Elemente ({count})",
"columnName": "Modellname",
"columnError": "Fehler",
"close": "Schließen",
"copyReport": "Bericht kopieren",
"retryFailed": "Fehlgeschlagene erneut versuchen ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -1679,6 +1768,7 @@
"recipeReplaced": "Rezept im Workflow ersetzt", "recipeReplaced": "Rezept im Workflow ersetzt",
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow", "recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar", "noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
"noPromptTargets": "Keine kompatiblen Prompt-Ziele im Workflow.\nKlicken Sie mit der rechten Maustaste auf einen Knoten in ComfyUI → Markieren als → Prompt-Ziel festlegen",
"noTargetNodeSelected": "Kein Zielknoten ausgewählt", "noTargetNodeSelected": "Kein Zielknoten ausgewählt",
"modelUpdated": "Modell im Workflow aktualisiert", "modelUpdated": "Modell im Workflow aktualisiert",
"modelFailed": "Fehler beim Aktualisieren des Modellknotens", "modelFailed": "Fehler beim Aktualisieren des Modellknotens",
@@ -1855,6 +1945,7 @@
"downloadPartialSuccess": "{completed} von {total} LoRAs heruntergeladen", "downloadPartialSuccess": "{completed} von {total} LoRAs heruntergeladen",
"downloadPartialWithAccess": "{completed} von {total} LoRAs heruntergeladen. {accessFailures} fehlgeschlagen aufgrund von Zugriffsbeschränkungen. Überprüfen Sie Ihren API-Schlüssel in den Einstellungen oder den Early Access-Status.", "downloadPartialWithAccess": "{completed} von {total} LoRAs heruntergeladen. {accessFailures} fehlgeschlagen aufgrund von Zugriffsbeschränkungen. Überprüfen Sie Ihren API-Schlüssel in den Einstellungen oder den Early Access-Status.",
"pleaseSelectVersion": "Bitte wählen Sie eine Version aus", "pleaseSelectVersion": "Bitte wählen Sie eine Version aus",
"pleaseSelectFile": "Bitte wählen Sie mindestens eine Datei aus",
"versionExists": "Diese Version existiert bereits in Ihrer Bibliothek", "versionExists": "Diese Version existiert bereits in Ihrer Bibliothek",
"downloadCompleted": "Download erfolgreich abgeschlossen", "downloadCompleted": "Download erfolgreich abgeschlossen",
"downloadSkippedByBaseModel": "Download übersprungen, weil das Basismodell {baseModel} ausgeschlossen ist", "downloadSkippedByBaseModel": "Download übersprungen, weil das Basismodell {baseModel} ausgeschlossen ist",
@@ -1929,6 +2020,12 @@
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})", "repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich", "repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}", "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...", "reimporting": "Rezept wird aus Quelle neu importiert...",
"reimportSuccess": "Rezept erfolgreich neu importiert", "reimportSuccess": "Rezept erfolgreich neu importiert",
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})", "reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
@@ -2037,7 +2134,6 @@
"presetNameTooLong": "Voreinstellungsname darf maximal {max} Zeichen haben", "presetNameTooLong": "Voreinstellungsname darf maximal {max} Zeichen haben",
"presetNameInvalidChars": "Voreinstellungsname enthält ungültige Zeichen", "presetNameInvalidChars": "Voreinstellungsname enthält ungültige Zeichen",
"presetNameExists": "Eine Voreinstellung mit diesem Namen existiert bereits", "presetNameExists": "Eine Voreinstellung mit diesem Namen existiert bereits",
"maxPresetsReached": "Maximal {max} Voreinstellungen erlaubt. Löschen Sie eine, um weitere hinzuzufügen.",
"presetNotFound": "Voreinstellung nicht gefunden", "presetNotFound": "Voreinstellung nicht gefunden",
"invalidPreset": "Ungültige Voreinstellungsdaten", "invalidPreset": "Ungültige Voreinstellungsdaten",
"deletePresetFailed": "Fehler beim Löschen der Voreinstellung", "deletePresetFailed": "Fehler beim Löschen der Voreinstellung",
@@ -2066,6 +2162,14 @@
"updateFailed": "Fehler beim Aktualisieren der Trigger Words", "updateFailed": "Fehler beim Aktualisieren der Trigger Words",
"copyFailed": "Kopieren fehlgeschlagen" "copyFailed": "Kopieren fehlgeschlagen"
}, },
"undo": {
"action": "Rückgängig",
"deleted": "Gelöscht: {name}",
"deletedBulk": "{count} Element(e) gelöscht",
"expired": "Undo-Fenster abgelaufen. Das Element wurde endgültig gelöscht.",
"failed": "Rückgängig machen fehlgeschlagen: {error}",
"restored": "Element wiederhergestellt"
},
"virtual": { "virtual": {
"loadFailed": "Fehler beim Laden der Elemente", "loadFailed": "Fehler beim Laden der Elemente",
"loadMoreFailed": "Fehler beim Laden weiterer Elemente", "loadMoreFailed": "Fehler beim Laden weiterer Elemente",
@@ -2129,6 +2233,7 @@
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}", "fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
"previewUpdated": "Vorschau erfolgreich aktualisiert", "previewUpdated": "Vorschau erfolgreich aktualisiert",
"previewUploadFailed": "Fehler beim Hochladen des Vorschaubilds", "previewUploadFailed": "Fehler beim Hochladen des Vorschaubilds",
"previewDropInvalid": "Nicht unterstützter Dateityp: {name}. Ziehen Sie stattdessen ein Bild oder ein MP4-Video hinein.",
"refreshComplete": "{action} abgeschlossen", "refreshComplete": "{action} abgeschlossen",
"refreshFailed": "Fehler beim {action} der {type}s", "refreshFailed": "Fehler beim {action} der {type}s",
"metadataRefreshed": "Metadaten erfolgreich aktualisiert", "metadataRefreshed": "Metadaten erfolgreich aktualisiert",
+111 -6
View File
@@ -186,6 +186,16 @@
"cancelled": "Repair cancelled. {count} recipes were repaired.", "cancelled": "Repair cancelled. {count} recipes were repaired.",
"error": "Recipe repair failed: {message}" "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}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "Manage Excluded Models" "label": "Manage Excluded Models"
}, },
@@ -449,6 +459,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)" "compact": "7 (1080p), 8 (2K), 10 (4K)"
}, },
"displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.", "displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.",
"recipesLayout": "Recipes Layout",
"recipesLayoutHelp": "Choose how recipe cards are arranged: a uniform grid or a masonry (Pinterest-style) layout that preserves each image's aspect ratio.",
"recipesLayoutOptions": {
"grid": "Grid",
"masonry": "Masonry"
},
"showFolderSidebar": "Show Folder Sidebar", "showFolderSidebar": "Show Folder Sidebar",
"showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.", "showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.",
"cardInfoDisplay": "Card Info Display", "cardInfoDisplay": "Card Info Display",
@@ -606,6 +622,10 @@
"label": "Hide Early Access Updates", "label": "Hide Early Access Updates",
"help": "When enabled, models with only early access updates will not show 'Update available' badge" "help": "When enabled, models with only early access updates will not show 'Update available' badge"
}, },
"hidePaidUpdates": {
"label": "Hide Paid Updates",
"help": "When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": { "licenseIcons": {
"useNewStyle": "Use updated license icons", "useNewStyle": "Use updated license icons",
"useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design." "useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design."
@@ -678,6 +698,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "Custom (OpenAI-compatible)" "custom": "Custom (OpenAI-compatible)"
}, },
@@ -761,6 +782,7 @@
"copyAll": "Copy Selected Syntax", "copyAll": "Copy Selected Syntax",
"refreshAll": "Refresh Selected Metadata", "refreshAll": "Refresh Selected Metadata",
"repairMetadata": "Repair Metadata for Selected", "repairMetadata": "Repair Metadata for Selected",
"rematchMetadata": "Rematch Selected to Local Models",
"reimportMetadata": "Re-import from Source", "reimportMetadata": "Re-import from Source",
"checkUpdates": "Check Updates for Selected", "checkUpdates": "Check Updates for Selected",
"moveAll": "Move Selected to Folder", "moveAll": "Move Selected to Folder",
@@ -816,6 +838,7 @@
"setContentRating": "Set Content Rating", "setContentRating": "Set Content Rating",
"moveToFolder": "Move to Folder", "moveToFolder": "Move to Folder",
"repairMetadata": "Repair metadata", "repairMetadata": "Repair metadata",
"rematchMetadata": "Rematch to local models",
"reimportMetadata": "Re-import from Source", "reimportMetadata": "Re-import from Source",
"excludeModel": "Exclude Model", "excludeModel": "Exclude Model",
"restoreModel": "Restore Model", "restoreModel": "Restore Model",
@@ -830,7 +853,8 @@
"recipes": { "recipes": {
"title": "LoRA Recipes", "title": "LoRA Recipes",
"actions": { "actions": {
"sendCheckpoint": "Send to ComfyUI" "sendCheckpoint": "Send to ComfyUI",
"sendRecipe": "Send to ComfyUI"
}, },
"controls": { "controls": {
"import": { "import": {
@@ -901,7 +925,9 @@
"dateAsc": "Oldest", "dateAsc": "Oldest",
"lorasCount": "LoRA Count", "lorasCount": "LoRA Count",
"lorasCountDesc": "Most", "lorasCountDesc": "Most",
"lorasCountAsc": "Least" "lorasCountAsc": "Least",
"opened": "Recently Opened",
"openedDesc": "Recently opened"
}, },
"refresh": { "refresh": {
"title": "Refresh recipe list", "title": "Refresh recipe list",
@@ -912,12 +938,25 @@
"favorites": { "favorites": {
"title": "Show Favorites Only", "title": "Show Favorites Only",
"action": "Favorites" "action": "Favorites"
},
"layout": {
"title": "Recipes Layout",
"grid": "Grid layout",
"masonry": "Masonry layout (Pinterest-style, preserves image aspect ratio)"
} }
}, },
"duplicates": { "duplicates": {
"found": "Found {count} duplicate groups", "found": "Found {count} duplicate groups",
"noGroups": "No duplicate groups found with the current matching basis",
"keepLatest": "Keep Latest Versions", "keepLatest": "Keep Latest Versions",
"deleteSelected": "Delete Selected" "deleteSelected": "Delete Selected",
"includePromptLabel": "Include prompt in matching",
"basis": {
"loraCombo": "Matched by: LoRA combination",
"loraComboAndPrompt": "Matched by: LoRA combination + prompt",
"hintLoraCombo": "Recipes with the same LoRAs at identical strengths are grouped.",
"hintPromptIncluded": "Recipes are grouped only when they use the same LoRAs at identical strengths AND have the same prompt."
}
}, },
"contextMenu": { "contextMenu": {
"copyRecipe": { "copyRecipe": {
@@ -1205,11 +1244,13 @@
"downloaded": "Downloaded", "downloaded": "Downloaded",
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.", "downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
"alreadyInLibrary": "Already in Library", "alreadyInLibrary": "Already in Library",
"partiallyDownloaded": "Partially downloaded",
"autoOrganizedPath": "[Auto-organized by path template]", "autoOrganizedPath": "[Auto-organized by path template]",
"fileSelection": { "fileSelection": {
"title": "Select File Format", "title": "Select File Format",
"files": "files", "files": "files",
"select": "Select File" "select": "Select File",
"inLibrary": "In Library"
}, },
"errors": { "errors": {
"invalidUrl": "Invalid Civitai URL format", "invalidUrl": "Invalid Civitai URL format",
@@ -1250,8 +1291,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "Frees {size}",
"title": "Delete Model", "title": "Delete Model",
"message": "Are you sure you want to delete this model and all associated files?" "message": "Are you sure you want to delete this model and all associated files?",
"recoverableWarning": "This will permanently delete the file after 20 seconds unless you undo."
},
"deleteRecipe": {
"recoverableWarning": "This action can be undone for 20 seconds."
}, },
"excludeModel": { "excludeModel": {
"title": "Exclude Model", "title": "Exclude Model",
@@ -1489,6 +1535,30 @@
"examples": "Loading examples...", "examples": "Loading examples...",
"versions": "Loading versions..." "versions": "Loading versions..."
}, },
"showcase": {
"hiddenBySfw": "{count} hidden by SFW-only setting",
"showExamples": "Show examples",
"showCount": "Show examples ({count})",
"hideExamples": "Hide examples",
"addExamples": "Add examples",
"previousExample": "Previous example",
"nextExample": "Next example",
"noExamples": "No example images available",
"addMoreExamples": "Add more examples",
"dragDrop": "Drag & drop images or videos here",
"or": "or",
"selectFiles": "Select Files",
"supportedFormats": "Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "Importing files...",
"noSupportedFiles": "No supported files selected. Please select image or video files.",
"allFiltered": "All example images are filtered due to NSFW content settings",
"sfwOnlyEnabled": "Your settings are currently set to show only safe-for-work content",
"changeInSettings": "You can change this in Settings",
"nsfwMature": "Mature Content",
"nsfwR": "R-rated Content",
"nsfwX": "X-rated Content",
"nsfwXxx": "XXX-rated Content"
},
"versions": { "versions": {
"heading": "Model versions", "heading": "Model versions",
"copy": "Track and manage every version of this model in one place.", "copy": "Track and manage every version of this model in one place.",
@@ -1516,6 +1586,8 @@
"newerTooltip": "This version is newer than your latest local version", "newerTooltip": "This version is newer than your latest local version",
"earlyAccess": "Early Access", "earlyAccess": "Early Access",
"earlyAccessTooltip": "This version currently requires Civitai early access", "earlyAccessTooltip": "This version currently requires Civitai early access",
"paid": "Paid",
"paidTooltip": "This version requires payment to download",
"ignored": "Ignored", "ignored": "Ignored",
"ignoredTooltip": "Update notifications are disabled for this version", "ignoredTooltip": "Update notifications are disabled for this version",
"onSiteOnly": "On-Site Only", "onSiteOnly": "On-Site Only",
@@ -1524,7 +1596,9 @@
"actions": { "actions": {
"download": "Download", "download": "Download",
"downloadTooltip": "Download this version", "downloadTooltip": "Download this version",
"downloadRemainingTooltip": "Download remaining files of this version",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai", "downloadEarlyAccessTooltip": "Download this early access version from Civitai",
"downloadPaidTooltip": "Download this paid version from Civitai",
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai", "downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
"delete": "Delete", "delete": "Delete",
"deleteTooltip": "Delete this local version", "deleteTooltip": "Delete this local version",
@@ -1581,6 +1655,21 @@
"downloadCsv": "Download CSV", "downloadCsv": "Download CSV",
"columnModelName": "Model Name", "columnModelName": "Model Name",
"columnError": "Error" "columnError": "Error"
},
"downloadBatchSummary": {
"title": "Batch Download Summary",
"statSuccess": "Success",
"statFailed": "Failed",
"statTotal": "Total",
"successMessage": "All {count} models downloaded successfully",
"completedWithErrors": "Completed with errors",
"failed": "Download failed",
"failedItems": "Failed Items ({count})",
"columnName": "Model Name",
"columnError": "Error",
"close": "Close",
"copyReport": "Copy Report",
"retryFailed": "Retry Failed ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -1679,6 +1768,7 @@
"recipeReplaced": "Recipe replaced in workflow", "recipeReplaced": "Recipe replaced in workflow",
"recipeFailedToSend": "Failed to send recipe to workflow", "recipeFailedToSend": "Failed to send recipe to workflow",
"noMatchingNodes": "No compatible nodes available in the current workflow", "noMatchingNodes": "No compatible nodes available in the current workflow",
"noPromptTargets": "No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "No target node selected", "noTargetNodeSelected": "No target node selected",
"modelUpdated": "Model updated in workflow", "modelUpdated": "Model updated in workflow",
"modelFailed": "Failed to update model node", "modelFailed": "Failed to update model node",
@@ -1855,6 +1945,7 @@
"downloadPartialSuccess": "Downloaded {completed} of {total} LoRAs", "downloadPartialSuccess": "Downloaded {completed} of {total} LoRAs",
"downloadPartialWithAccess": "Downloaded {completed} of {total} LoRAs. {accessFailures} failed due to access restrictions. Check your API key in settings or early access status.", "downloadPartialWithAccess": "Downloaded {completed} of {total} LoRAs. {accessFailures} failed due to access restrictions. Check your API key in settings or early access status.",
"pleaseSelectVersion": "Please select a version", "pleaseSelectVersion": "Please select a version",
"pleaseSelectFile": "Please select at least one file",
"versionExists": "This version already exists in your library", "versionExists": "This version already exists in your library",
"downloadCompleted": "Download completed successfully", "downloadCompleted": "Download completed successfully",
"downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded", "downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded",
@@ -1929,6 +2020,12 @@
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})", "repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes", "repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
"repairBulkFailed": "Failed to repair selected recipes: {message}", "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...", "reimporting": "Re-importing recipe from source...",
"reimportSuccess": "Recipe re-imported successfully", "reimportSuccess": "Recipe re-imported successfully",
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})", "reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
@@ -2037,7 +2134,6 @@
"presetNameTooLong": "Preset name must be {max} characters or less", "presetNameTooLong": "Preset name must be {max} characters or less",
"presetNameInvalidChars": "Preset name contains invalid characters", "presetNameInvalidChars": "Preset name contains invalid characters",
"presetNameExists": "A preset with this name already exists", "presetNameExists": "A preset with this name already exists",
"maxPresetsReached": "Maximum {max} presets allowed. Delete one to add more.",
"presetNotFound": "Preset not found", "presetNotFound": "Preset not found",
"invalidPreset": "Invalid preset data", "invalidPreset": "Invalid preset data",
"deletePresetFailed": "Failed to delete preset", "deletePresetFailed": "Failed to delete preset",
@@ -2066,6 +2162,14 @@
"updateFailed": "Failed to update trigger words", "updateFailed": "Failed to update trigger words",
"copyFailed": "Copy failed" "copyFailed": "Copy failed"
}, },
"undo": {
"action": "Undo",
"deleted": "Deleted {name}",
"deletedBulk": "Deleted {count} item(s)",
"expired": "Undo window expired. The item was permanently deleted.",
"failed": "Undo failed: {error}",
"restored": "Item restored"
},
"virtual": { "virtual": {
"loadFailed": "Failed to load items", "loadFailed": "Failed to load items",
"loadMoreFailed": "Failed to load more items", "loadMoreFailed": "Failed to load more items",
@@ -2129,6 +2233,7 @@
"fileRenameFailed": "Failed to rename file: {error}", "fileRenameFailed": "Failed to rename file: {error}",
"previewUpdated": "Preview updated successfully", "previewUpdated": "Preview updated successfully",
"previewUploadFailed": "Failed to upload preview image", "previewUploadFailed": "Failed to upload preview image",
"previewDropInvalid": "Unsupported file type: {name}. Drop an image or MP4 video instead.",
"refreshComplete": "{action} complete", "refreshComplete": "{action} complete",
"refreshFailed": "Failed to {action} {type}s", "refreshFailed": "Failed to {action} {type}s",
"metadataRefreshed": "Metadata refreshed successfully", "metadataRefreshed": "Metadata refreshed successfully",
+111 -6
View File
@@ -186,6 +186,16 @@
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.", "cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
"error": "Error al reparar recetas: {message}" "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}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "Gestionar modelos excluidos" "label": "Gestionar modelos excluidos"
}, },
@@ -449,6 +459,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)" "compact": "7 (1080p), 8 (2K), 10 (4K)"
}, },
"displayDensityWarning": "Advertencia: Densidades más altas pueden causar problemas de rendimiento en sistemas con recursos limitados.", "displayDensityWarning": "Advertencia: Densidades más altas pueden causar problemas de rendimiento en sistemas con recursos limitados.",
"recipesLayout": "Diseño de recetas",
"recipesLayoutHelp": "Elige cómo se organizan las tarjetas de recetas: una cuadrícula uniforme o un diseño masonry (estilo Pinterest) que conserva la proporción de aspecto de cada imagen.",
"recipesLayoutOptions": {
"grid": "Cuadrícula",
"masonry": "Masonry"
},
"showFolderSidebar": "Mostrar barra lateral de carpetas", "showFolderSidebar": "Mostrar barra lateral de carpetas",
"showFolderSidebarHelp": "Activa o desactiva la barra lateral de navegación de carpetas en las páginas de modelos. Cuando está desactivada, la barra lateral y el área de desplazamiento permanecen ocultas.", "showFolderSidebarHelp": "Activa o desactiva la barra lateral de navegación de carpetas en las páginas de modelos. Cuando está desactivada, la barra lateral y el área de desplazamiento permanecen ocultas.",
"cardInfoDisplay": "Visualización de información de tarjeta", "cardInfoDisplay": "Visualización de información de tarjeta",
@@ -606,6 +622,10 @@
"label": "Ocultar actualizaciones de acceso temprano", "label": "Ocultar actualizaciones de acceso temprano",
"help": "Solo actualizaciones de acceso temprano" "help": "Solo actualizaciones de acceso temprano"
}, },
"hidePaidUpdates": {
"label": "Ocultar actualizaciones de pago",
"help": "Cuando está activado, los modelos que solo tienen actualizaciones de pago no mostrarán la insignia de 'Actualización disponible'"
},
"licenseIcons": { "licenseIcons": {
"useNewStyle": "Usar iconos de licencia actualizados", "useNewStyle": "Usar iconos de licencia actualizados",
"useNewStyleHelp": "Mostrar permisos de licencia con indicadores de color (nuevo estilo) o solo iconos de restricción (estilo clásico). Refleja el diseño actual de CivitAI." "useNewStyleHelp": "Mostrar permisos de licencia con indicadores de color (nuevo estilo) o solo iconos de restricción (estilo clásico). Refleja el diseño actual de CivitAI."
@@ -678,6 +698,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "Personalizado (compatible con OpenAI)" "custom": "Personalizado (compatible con OpenAI)"
}, },
@@ -761,6 +782,7 @@
"copyAll": "Copiar toda la sintaxis", "copyAll": "Copiar toda la sintaxis",
"refreshAll": "Actualizar todos los metadatos", "refreshAll": "Actualizar todos los metadatos",
"repairMetadata": "Reparar metadatos de la selección", "repairMetadata": "Reparar metadatos de la selección",
"rematchMetadata": "Reasociar los seleccionados con modelos locales",
"reimportMetadata": "Reimportar desde origen", "reimportMetadata": "Reimportar desde origen",
"checkUpdates": "Comprobar actualizaciones para la selección", "checkUpdates": "Comprobar actualizaciones para la selección",
"moveAll": "Mover todos a carpeta", "moveAll": "Mover todos a carpeta",
@@ -816,6 +838,7 @@
"setContentRating": "Establecer clasificación de contenido", "setContentRating": "Establecer clasificación de contenido",
"moveToFolder": "Mover a carpeta", "moveToFolder": "Mover a carpeta",
"repairMetadata": "Reparar metadatos", "repairMetadata": "Reparar metadatos",
"rematchMetadata": "Reasociar con modelos locales",
"reimportMetadata": "Reimportar desde origen", "reimportMetadata": "Reimportar desde origen",
"excludeModel": "Excluir modelo", "excludeModel": "Excluir modelo",
"restoreModel": "Restaurar modelo", "restoreModel": "Restaurar modelo",
@@ -830,7 +853,8 @@
"recipes": { "recipes": {
"title": "Recetas de LoRA", "title": "Recetas de LoRA",
"actions": { "actions": {
"sendCheckpoint": "Enviar a ComfyUI" "sendCheckpoint": "Enviar a ComfyUI",
"sendRecipe": "Enviar a ComfyUI"
}, },
"controls": { "controls": {
"import": { "import": {
@@ -901,7 +925,9 @@
"dateAsc": "Más antiguo", "dateAsc": "Más antiguo",
"lorasCount": "Cant. de LoRAs", "lorasCount": "Cant. de LoRAs",
"lorasCountDesc": "Más", "lorasCountDesc": "Más",
"lorasCountAsc": "Menos" "lorasCountAsc": "Menos",
"opened": "Abiertos recientemente",
"openedDesc": "Abiertos recientemente"
}, },
"refresh": { "refresh": {
"title": "Actualizar lista de recetas", "title": "Actualizar lista de recetas",
@@ -912,12 +938,25 @@
"favorites": { "favorites": {
"title": "Mostrar solo favoritos", "title": "Mostrar solo favoritos",
"action": "Favoritos" "action": "Favoritos"
},
"layout": {
"title": "Diseño de recetas",
"grid": "Vista de cuadrícula",
"masonry": "Vista masonry (estilo Pinterest, conserva la proporción de aspecto de la imagen)"
} }
}, },
"duplicates": { "duplicates": {
"found": "Se encontraron {count} grupos de duplicados", "found": "Se encontraron {count} grupos de duplicados",
"noGroups": "No se encontraron grupos de duplicados con el criterio de coincidencia actual",
"keepLatest": "Mantener versiones más recientes", "keepLatest": "Mantener versiones más recientes",
"deleteSelected": "Eliminar seleccionados" "deleteSelected": "Eliminar seleccionados",
"includePromptLabel": "Incluir prompt en la coincidencia",
"basis": {
"loraCombo": "Coincidencia por: combinación de LoRA",
"loraComboAndPrompt": "Coincidencia por: combinación de LoRA + prompt",
"hintLoraCombo": "Se agrupan las recetas con los mismos LoRAs y las mismas intensidades.",
"hintPromptIncluded": "Las recetas solo se agrupan cuando usan los mismos LoRAs con intensidades idénticas Y tienen el mismo prompt."
}
}, },
"contextMenu": { "contextMenu": {
"copyRecipe": { "copyRecipe": {
@@ -1205,11 +1244,13 @@
"downloaded": "Descargado", "downloaded": "Descargado",
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.", "downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
"alreadyInLibrary": "Ya en la biblioteca", "alreadyInLibrary": "Ya en la biblioteca",
"partiallyDownloaded": "Descargado parcialmente",
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]", "autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
"fileSelection": { "fileSelection": {
"title": "Seleccionar formato de archivo", "title": "Seleccionar formato de archivo",
"files": "archivos", "files": "archivos",
"select": "Seleccionar archivo" "select": "Seleccionar archivo",
"inLibrary": "En la biblioteca"
}, },
"errors": { "errors": {
"invalidUrl": "Formato de URL de Civitai inválido", "invalidUrl": "Formato de URL de Civitai inválido",
@@ -1250,8 +1291,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "Libera {size}",
"title": "Eliminar modelo", "title": "Eliminar modelo",
"message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?" "message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?",
"recoverableWarning": "El archivo se eliminará permanentemente después de 20 segundos a menos que deshaga la acción."
},
"deleteRecipe": {
"recoverableWarning": "Esta acción se puede deshacer durante 20 segundos."
}, },
"excludeModel": { "excludeModel": {
"title": "Excluir modelo", "title": "Excluir modelo",
@@ -1489,6 +1535,30 @@
"examples": "Cargando ejemplos...", "examples": "Cargando ejemplos...",
"versions": "Cargando versiones..." "versions": "Cargando versiones..."
}, },
"showcase": {
"hiddenBySfw": "{count} ocultas por el ajuste de solo contenido SFW",
"showExamples": "Mostrar ejemplos",
"showCount": "Mostrar ejemplos ({count})",
"hideExamples": "Ocultar ejemplos",
"addExamples": "Añadir ejemplos",
"previousExample": "Ejemplo anterior",
"nextExample": "Ejemplo siguiente",
"noExamples": "No hay imágenes de ejemplo disponibles",
"addMoreExamples": "Añadir más ejemplos",
"dragDrop": "Arrastra y suelta imágenes o videos aquí",
"or": "o",
"selectFiles": "Seleccionar archivos",
"supportedFormats": "Formatos compatibles: jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "Importando archivos...",
"noSupportedFiles": "No se seleccionaron archivos compatibles. Selecciona archivos de imagen o video.",
"allFiltered": "Todas las imágenes de ejemplo están filtradas por los ajustes de contenido NSFW",
"sfwOnlyEnabled": "Tus ajustes están configurados actualmente para mostrar solo contenido apto para todo público",
"changeInSettings": "Puedes cambiarlo en Configuración",
"nsfwMature": "Contenido para adultos",
"nsfwR": "Contenido clasificación R",
"nsfwX": "Contenido clasificación X",
"nsfwXxx": "Contenido clasificación XXX"
},
"versions": { "versions": {
"heading": "Versiones del modelo", "heading": "Versiones del modelo",
"copy": "Administra todas las versiones de este modelo en un solo lugar.", "copy": "Administra todas las versiones de este modelo en un solo lugar.",
@@ -1516,6 +1586,8 @@
"newerTooltip": "Esta versión es más reciente que tu última versión local", "newerTooltip": "Esta versión es más reciente que tu última versión local",
"earlyAccess": "Acceso temprano", "earlyAccess": "Acceso temprano",
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai", "earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
"paid": "De pago",
"paidTooltip": "Esta versión requiere pago para descargarse",
"ignored": "Ignorada", "ignored": "Ignorada",
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión", "ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
"onSiteOnly": "Solo en Sitio", "onSiteOnly": "Solo en Sitio",
@@ -1524,7 +1596,9 @@
"actions": { "actions": {
"download": "Descargar", "download": "Descargar",
"downloadTooltip": "Descargar esta versión", "downloadTooltip": "Descargar esta versión",
"downloadRemainingTooltip": "Descargar los archivos restantes de esta versión",
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai", "downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
"downloadPaidTooltip": "Descargar esta versión de pago desde Civitai",
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai", "downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
"delete": "Eliminar", "delete": "Eliminar",
"deleteTooltip": "Eliminar esta versión local", "deleteTooltip": "Eliminar esta versión local",
@@ -1581,6 +1655,21 @@
"downloadCsv": "Descargar CSV", "downloadCsv": "Descargar CSV",
"columnModelName": "Nombre del modelo", "columnModelName": "Nombre del modelo",
"columnError": "Error" "columnError": "Error"
},
"downloadBatchSummary": {
"title": "Resumen de descarga por lotes",
"statSuccess": "Correctos",
"statFailed": "Fallidos",
"statTotal": "Total",
"successMessage": "Todos los {count} modelos se descargaron correctamente",
"completedWithErrors": "Completado con errores",
"failed": "Descarga fallida",
"failedItems": "Elementos fallidos ({count})",
"columnName": "Nombre del modelo",
"columnError": "Error",
"close": "Cerrar",
"copyReport": "Copiar informe",
"retryFailed": "Reintentar fallidos ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -1679,6 +1768,7 @@
"recipeReplaced": "Receta reemplazada en el flujo de trabajo", "recipeReplaced": "Receta reemplazada en el flujo de trabajo",
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo", "recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual", "noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
"noPromptTargets": "No hay destinos de prompt compatibles en el workflow.\nHaz clic derecho en un nodo de ComfyUI → Marcar como → Destino de envío de prompt",
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino", "noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
"modelUpdated": "Modelo actualizado en el flujo de trabajo", "modelUpdated": "Modelo actualizado en el flujo de trabajo",
"modelFailed": "Error al actualizar nodo de modelo", "modelFailed": "Error al actualizar nodo de modelo",
@@ -1855,6 +1945,7 @@
"downloadPartialSuccess": "Descargados {completed} de {total} LoRAs", "downloadPartialSuccess": "Descargados {completed} de {total} LoRAs",
"downloadPartialWithAccess": "Descargados {completed} de {total} LoRAs. {accessFailures} fallaron debido a restricciones de acceso. Revisa tu clave API en configuración o estado de acceso temprano.", "downloadPartialWithAccess": "Descargados {completed} de {total} LoRAs. {accessFailures} fallaron debido a restricciones de acceso. Revisa tu clave API en configuración o estado de acceso temprano.",
"pleaseSelectVersion": "Por favor selecciona una versión", "pleaseSelectVersion": "Por favor selecciona una versión",
"pleaseSelectFile": "Por favor selecciona al menos un archivo",
"versionExists": "Esta versión ya existe en tu biblioteca", "versionExists": "Esta versión ya existe en tu biblioteca",
"downloadCompleted": "Descarga completada exitosamente", "downloadCompleted": "Descarga completada exitosamente",
"downloadSkippedByBaseModel": "Descarga omitida porque el modelo base {baseModel} está excluido", "downloadSkippedByBaseModel": "Descarga omitida porque el modelo base {baseModel} está excluido",
@@ -1929,6 +2020,12 @@
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})", "repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas", "repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}", "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...", "reimporting": "Reimportando receta desde origen...",
"reimportSuccess": "Receta reimportada exitosamente", "reimportSuccess": "Receta reimportada exitosamente",
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})", "reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
@@ -2037,7 +2134,6 @@
"presetNameTooLong": "El nombre del preajuste debe tener {max} caracteres o menos", "presetNameTooLong": "El nombre del preajuste debe tener {max} caracteres o menos",
"presetNameInvalidChars": "El nombre del preajuste contiene caracteres inválidos", "presetNameInvalidChars": "El nombre del preajuste contiene caracteres inválidos",
"presetNameExists": "Ya existe un preajuste con este nombre", "presetNameExists": "Ya existe un preajuste con este nombre",
"maxPresetsReached": "Máximo {max} preajustes permitidos. Elimine uno para agregar más.",
"presetNotFound": "Preajuste no encontrado", "presetNotFound": "Preajuste no encontrado",
"invalidPreset": "Datos de preajuste inválidos", "invalidPreset": "Datos de preajuste inválidos",
"deletePresetFailed": "Error al eliminar el preajuste", "deletePresetFailed": "Error al eliminar el preajuste",
@@ -2066,6 +2162,14 @@
"updateFailed": "Error al actualizar palabras clave", "updateFailed": "Error al actualizar palabras clave",
"copyFailed": "Error al copiar" "copyFailed": "Error al copiar"
}, },
"undo": {
"action": "Deshacer",
"deleted": "Eliminado: {name}",
"deletedBulk": "{count} elemento(s) eliminado(s)",
"expired": "La ventana de deshacer ha caducado. El elemento se eliminó permanentemente.",
"failed": "No se pudo deshacer: {error}",
"restored": "Elemento restaurado"
},
"virtual": { "virtual": {
"loadFailed": "Error al cargar elementos", "loadFailed": "Error al cargar elementos",
"loadMoreFailed": "Error al cargar más elementos", "loadMoreFailed": "Error al cargar más elementos",
@@ -2129,6 +2233,7 @@
"fileRenameFailed": "Error al renombrar archivo: {error}", "fileRenameFailed": "Error al renombrar archivo: {error}",
"previewUpdated": "Vista previa actualizada exitosamente", "previewUpdated": "Vista previa actualizada exitosamente",
"previewUploadFailed": "Error al subir imagen de vista previa", "previewUploadFailed": "Error al subir imagen de vista previa",
"previewDropInvalid": "Tipo de archivo no admitido: {name}. Arrastra una imagen o un video MP4 en su lugar.",
"refreshComplete": "{action} completada", "refreshComplete": "{action} completada",
"refreshFailed": "Error al {action} {type}s", "refreshFailed": "Error al {action} {type}s",
"metadataRefreshed": "Metadatos actualizados exitosamente", "metadataRefreshed": "Metadatos actualizados exitosamente",
+111 -6
View File
@@ -186,6 +186,16 @@
"cancelled": "Réparation annulée. {count} recettes ont été réparées.", "cancelled": "Réparation annulée. {count} recettes ont été réparées.",
"error": "Échec de la réparation des recettes : {message}" "error": "Échec de la réparation des recettes : {message}"
}, },
"rematchRecipes": {
"label": "Réassocier les recettes aux modèles locaux",
"loading": "Réassociation des recettes aux modèles locaux...",
"success": "{entries} entrées associées dans {recipes} recettes",
"successErrors": "{entries} entrées associées dans {recipes} recettes, {failures} échecs",
"allFailed": "Échec de la réassociation de {failures} recettes sur {total}",
"noMatch": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} recettes",
"cancelled": "Réassociation annulée. {recipes} recettes mises à jour ({entries} entrées)",
"error": "Échec de la réassociation des recettes : {message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "Gérer les modèles exclus" "label": "Gérer les modèles exclus"
}, },
@@ -449,6 +459,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)" "compact": "7 (1080p), 8 (2K), 10 (4K)"
}, },
"displayDensityWarning": "Attention : Des densités plus élevées peuvent causer des problèmes de performance sur les systèmes avec des ressources limitées.", "displayDensityWarning": "Attention : Des densités plus élevées peuvent causer des problèmes de performance sur les systèmes avec des ressources limitées.",
"recipesLayout": "Disposition des recettes",
"recipesLayoutHelp": "Choisissez comment les cartes de recettes sont organisées : une grille uniforme ou une disposition masonry (style Pinterest) qui préserve le rapport d'aspect de chaque image.",
"recipesLayoutOptions": {
"grid": "Grille",
"masonry": "Masonry"
},
"showFolderSidebar": "Afficher la barre latérale des dossiers", "showFolderSidebar": "Afficher la barre latérale des dossiers",
"showFolderSidebarHelp": "Activez ou désactivez la barre latérale de navigation des dossiers sur les pages de modèles. Lorsqu'elle est désactivée, la barre latérale et la zone de survol restent masquées.", "showFolderSidebarHelp": "Activez ou désactivez la barre latérale de navigation des dossiers sur les pages de modèles. Lorsqu'elle est désactivée, la barre latérale et la zone de survol restent masquées.",
"cardInfoDisplay": "Affichage des informations de carte", "cardInfoDisplay": "Affichage des informations de carte",
@@ -606,6 +622,10 @@
"label": "Masquer les mises à jour en accès anticipé", "label": "Masquer les mises à jour en accès anticipé",
"help": "Seulement les mises à jour en accès anticipé" "help": "Seulement les mises à jour en accès anticipé"
}, },
"hidePaidUpdates": {
"label": "Masquer les mises à jour payantes",
"help": "Lorsque cette option est activée, les modèles n'ayant que des mises à jour payantes n'affichent pas le badge « Mise à jour disponible »"
},
"licenseIcons": { "licenseIcons": {
"useNewStyle": "Utiliser les icônes de licence mises à jour", "useNewStyle": "Utiliser les icônes de licence mises à jour",
"useNewStyleHelp": "Afficher les permissions de licence avec des indicateurs colorés (nouveau style) ou des icônes de restriction uniquement (style classique). Reprend le design actuel de CivitAI." "useNewStyleHelp": "Afficher les permissions de licence avec des indicateurs colorés (nouveau style) ou des icônes de restriction uniquement (style classique). Reprend le design actuel de CivitAI."
@@ -678,6 +698,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "Personnalisé (compatible OpenAI)" "custom": "Personnalisé (compatible OpenAI)"
}, },
@@ -761,6 +782,7 @@
"copyAll": "Copier toute la syntaxe", "copyAll": "Copier toute la syntaxe",
"refreshAll": "Actualiser toutes les métadonnées", "refreshAll": "Actualiser toutes les métadonnées",
"repairMetadata": "Réparer les métadonnées de la sélection", "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", "reimportMetadata": "Ré-importer depuis la source",
"checkUpdates": "Vérifier les mises à jour pour la sélection", "checkUpdates": "Vérifier les mises à jour pour la sélection",
"moveAll": "Déplacer tout vers un dossier", "moveAll": "Déplacer tout vers un dossier",
@@ -816,6 +838,7 @@
"setContentRating": "Définir la classification du contenu", "setContentRating": "Définir la classification du contenu",
"moveToFolder": "Déplacer vers un dossier", "moveToFolder": "Déplacer vers un dossier",
"repairMetadata": "Réparer les métadonnées", "repairMetadata": "Réparer les métadonnées",
"rematchMetadata": "Réassocier aux modèles locaux",
"reimportMetadata": "Ré-importer depuis la source", "reimportMetadata": "Ré-importer depuis la source",
"excludeModel": "Exclure le modèle", "excludeModel": "Exclure le modèle",
"restoreModel": "Restaurer le modèle", "restoreModel": "Restaurer le modèle",
@@ -830,7 +853,8 @@
"recipes": { "recipes": {
"title": "LoRA Recipes", "title": "LoRA Recipes",
"actions": { "actions": {
"sendCheckpoint": "Envoyer vers ComfyUI" "sendCheckpoint": "Envoyer vers ComfyUI",
"sendRecipe": "Envoyer vers ComfyUI"
}, },
"controls": { "controls": {
"import": { "import": {
@@ -901,7 +925,9 @@
"dateAsc": "Plus ancien", "dateAsc": "Plus ancien",
"lorasCount": "Nombre de LoRAs", "lorasCount": "Nombre de LoRAs",
"lorasCountDesc": "Plus", "lorasCountDesc": "Plus",
"lorasCountAsc": "Moins" "lorasCountAsc": "Moins",
"opened": "Récemment ouverts",
"openedDesc": "Récemment ouverts"
}, },
"refresh": { "refresh": {
"title": "Actualiser la liste des recipes", "title": "Actualiser la liste des recipes",
@@ -912,12 +938,25 @@
"favorites": { "favorites": {
"title": "Afficher uniquement les favoris", "title": "Afficher uniquement les favoris",
"action": "Favoris" "action": "Favoris"
},
"layout": {
"title": "Disposition des recettes",
"grid": "Disposition en grille",
"masonry": "Disposition masonry (style Pinterest, préserve le rapport d'aspect de l'image)"
} }
}, },
"duplicates": { "duplicates": {
"found": "Trouvé {count} groupes de doublons", "found": "Trouvé {count} groupes de doublons",
"noGroups": "Aucun groupe de doublons trouvé avec le critère de correspondance actuel",
"keepLatest": "Garder les dernières versions", "keepLatest": "Garder les dernières versions",
"deleteSelected": "Supprimer la sélection" "deleteSelected": "Supprimer la sélection",
"includePromptLabel": "Inclure le prompt dans la correspondance",
"basis": {
"loraCombo": "Correspondance : combinaison de LoRA",
"loraComboAndPrompt": "Correspondance : combinaison de LoRA + prompt",
"hintLoraCombo": "Les recettes avec les mêmes LoRAs et des forces identiques sont regroupées.",
"hintPromptIncluded": "Les recettes ne sont regroupées que si elles utilisent les mêmes LoRAs avec des forces identiques ET ont le même prompt."
}
}, },
"contextMenu": { "contextMenu": {
"copyRecipe": { "copyRecipe": {
@@ -1205,11 +1244,13 @@
"downloaded": "Téléchargé", "downloaded": "Téléchargé",
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.", "downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
"alreadyInLibrary": "Déjà dans la bibliothèque", "alreadyInLibrary": "Déjà dans la bibliothèque",
"partiallyDownloaded": "Téléchargé partiellement",
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]", "autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
"fileSelection": { "fileSelection": {
"title": "Choisir le format de fichier", "title": "Choisir le format de fichier",
"files": "fichiers", "files": "fichiers",
"select": "Choisir le fichier" "select": "Choisir le fichier",
"inLibrary": "Dans la bibliothèque"
}, },
"errors": { "errors": {
"invalidUrl": "Format d'URL Civitai invalide", "invalidUrl": "Format d'URL Civitai invalide",
@@ -1250,8 +1291,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "Libère {size}",
"title": "Supprimer le modèle", "title": "Supprimer le modèle",
"message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?" "message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?",
"recoverableWarning": "Le fichier sera définitivement supprimé après 20 secondes, sauf si vous annulez."
},
"deleteRecipe": {
"recoverableWarning": "Cette action peut être annulée pendant 20 secondes."
}, },
"excludeModel": { "excludeModel": {
"title": "Exclure le modèle", "title": "Exclure le modèle",
@@ -1489,6 +1535,30 @@
"examples": "Chargement des exemples...", "examples": "Chargement des exemples...",
"versions": "Chargement des versions..." "versions": "Chargement des versions..."
}, },
"showcase": {
"hiddenBySfw": "{count} masqué(s) par le paramètre « Contenu SFW uniquement »",
"showExamples": "Afficher les exemples",
"showCount": "Afficher les exemples ({count})",
"hideExamples": "Masquer les exemples",
"addExamples": "Ajouter des exemples",
"previousExample": "Exemple précédent",
"nextExample": "Exemple suivant",
"noExamples": "Aucune image d'exemple disponible",
"addMoreExamples": "Ajouter d'autres exemples",
"dragDrop": "Glissez-déposez des images ou des vidéos ici",
"or": "ou",
"selectFiles": "Sélectionner des fichiers",
"supportedFormats": "Formats pris en charge : jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "Importation des fichiers...",
"noSupportedFiles": "Aucun fichier pris en charge sélectionné. Veuillez sélectionner des fichiers image ou vidéo.",
"allFiltered": "Toutes les images d'exemple sont filtrées en raison des paramètres de contenu NSFW",
"sfwOnlyEnabled": "Vos paramètres sont actuellement configurés pour n'afficher que du contenu tout public",
"changeInSettings": "Vous pouvez modifier cela dans les paramètres",
"nsfwMature": "Contenu pour adultes",
"nsfwR": "Contenu classé R",
"nsfwX": "Contenu classé X",
"nsfwXxx": "Contenu classé XXX"
},
"versions": { "versions": {
"heading": "Versions du modèle", "heading": "Versions du modèle",
"copy": "Gérez toutes les versions de ce modèle en un seul endroit.", "copy": "Gérez toutes les versions de ce modèle en un seul endroit.",
@@ -1516,6 +1586,8 @@
"newerTooltip": "Cette version est plus récente que votre dernière version locale", "newerTooltip": "Cette version est plus récente que votre dernière version locale",
"earlyAccess": "Accès anticipé", "earlyAccess": "Accès anticipé",
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai", "earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
"paid": "Payant",
"paidTooltip": "Cette version nécessite un paiement pour être téléchargée",
"ignored": "Ignorée", "ignored": "Ignorée",
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version", "ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
"onSiteOnly": "Uniquement sur Site", "onSiteOnly": "Uniquement sur Site",
@@ -1524,7 +1596,9 @@
"actions": { "actions": {
"download": "Télécharger", "download": "Télécharger",
"downloadTooltip": "Télécharger cette version", "downloadTooltip": "Télécharger cette version",
"downloadRemainingTooltip": "Télécharger les fichiers restants de cette version",
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai", "downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
"downloadPaidTooltip": "Télécharger cette version payante depuis Civitai",
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai", "downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
"delete": "Supprimer", "delete": "Supprimer",
"deleteTooltip": "Supprimer cette version locale", "deleteTooltip": "Supprimer cette version locale",
@@ -1581,6 +1655,21 @@
"downloadCsv": "Télécharger CSV", "downloadCsv": "Télécharger CSV",
"columnModelName": "Nom du modèle", "columnModelName": "Nom du modèle",
"columnError": "Erreur" "columnError": "Erreur"
},
"downloadBatchSummary": {
"title": "Résumé du téléchargement groupé",
"statSuccess": "Réussis",
"statFailed": "Échoués",
"statTotal": "Total",
"successMessage": "Les {count} modèles ont été téléchargés avec succès",
"completedWithErrors": "Terminé avec des erreurs",
"failed": "Échec du téléchargement",
"failedItems": "Éléments échoués ({count})",
"columnName": "Nom du modèle",
"columnError": "Erreur",
"close": "Fermer",
"copyReport": "Copier le rapport",
"retryFailed": "Réessayer les échecs ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -1679,6 +1768,7 @@
"recipeReplaced": "Recipe remplacée dans le workflow", "recipeReplaced": "Recipe remplacée dans le workflow",
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow", "recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel", "noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
"noPromptTargets": "Aucune cible de prompt compatible dans le workflow.\nFaites un clic droit sur un nœud dans ComfyUI → Marquer comme → Cible d'envoi du prompt",
"noTargetNodeSelected": "Aucun nœud cible sélectionné", "noTargetNodeSelected": "Aucun nœud cible sélectionné",
"modelUpdated": "Modèle mis à jour dans le workflow", "modelUpdated": "Modèle mis à jour dans le workflow",
"modelFailed": "Échec de la mise à jour du nœud modèle", "modelFailed": "Échec de la mise à jour du nœud modèle",
@@ -1855,6 +1945,7 @@
"downloadPartialSuccess": "{completed} sur {total} LoRAs téléchargés", "downloadPartialSuccess": "{completed} sur {total} LoRAs téléchargés",
"downloadPartialWithAccess": "{completed} sur {total} LoRAs téléchargés. {accessFailures} ont échoué en raison de restrictions d'accès. Vérifiez votre clé API dans les paramètres ou le statut d'accès anticipé.", "downloadPartialWithAccess": "{completed} sur {total} LoRAs téléchargés. {accessFailures} ont échoué en raison de restrictions d'accès. Vérifiez votre clé API dans les paramètres ou le statut d'accès anticipé.",
"pleaseSelectVersion": "Veuillez sélectionner une version", "pleaseSelectVersion": "Veuillez sélectionner une version",
"pleaseSelectFile": "Veuillez sélectionner au moins un fichier",
"versionExists": "Cette version existe déjà dans votre bibliothèque", "versionExists": "Cette version existe déjà dans votre bibliothèque",
"downloadCompleted": "Téléchargement terminé avec succès", "downloadCompleted": "Téléchargement terminé avec succès",
"downloadSkippedByBaseModel": "Téléchargement ignoré, car le modèle de base {baseModel} est exclu", "downloadSkippedByBaseModel": "Téléchargement ignoré, car le modèle de base {baseModel} est exclu",
@@ -1929,6 +2020,12 @@
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})", "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} recettes sélectionnées", "repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} recettes sélectionnées",
"repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}", "repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}",
"rematchComplete": "{entries} entrées associées dans {recipes} recettes",
"rematchCompleteErrors": "{entries} entrées associées dans {recipes} recettes, {failures} échecs",
"rematchAllFailed": "Échec de la réassociation de {failures} recettes sélectionnées sur {total}",
"rematchUnmatched": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} recettes",
"rematchSkipped": "Aucune des {total} recettes sélectionnées ne nécessite de réassociation",
"rematchFailed": "Échec de la réassociation des recettes sélectionnées : {message}",
"reimporting": "Ré-import de la recette depuis la source...", "reimporting": "Ré-import de la recette depuis la source...",
"reimportSuccess": "Recette ré-importée avec succès", "reimportSuccess": "Recette ré-importée avec succès",
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})", "reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
@@ -2037,7 +2134,6 @@
"presetNameTooLong": "Le nom du préréglage doit contenir au maximum {max} caractères", "presetNameTooLong": "Le nom du préréglage doit contenir au maximum {max} caractères",
"presetNameInvalidChars": "Le nom du préréglage contient des caractères invalides", "presetNameInvalidChars": "Le nom du préréglage contient des caractères invalides",
"presetNameExists": "Un préréglage avec ce nom existe déjà", "presetNameExists": "Un préréglage avec ce nom existe déjà",
"maxPresetsReached": "Maximum {max} préréglages autorisés. Supprimez-en un pour en ajouter plus.",
"presetNotFound": "Préréglage non trouvé", "presetNotFound": "Préréglage non trouvé",
"invalidPreset": "Données de préréglage invalides", "invalidPreset": "Données de préréglage invalides",
"deletePresetFailed": "Échec de la suppression du préréglage", "deletePresetFailed": "Échec de la suppression du préréglage",
@@ -2066,6 +2162,14 @@
"updateFailed": "Échec de la mise à jour des mots-clés", "updateFailed": "Échec de la mise à jour des mots-clés",
"copyFailed": "Échec de la copie" "copyFailed": "Échec de la copie"
}, },
"undo": {
"action": "Annuler",
"deleted": "Supprimé : {name}",
"deletedBulk": "{count} élément(s) supprimé(s)",
"expired": "La fenêtre d'annulation a expiré. L'élément a été définitivement supprimé.",
"failed": "Échec de l'annulation : {error}",
"restored": "Élément restauré"
},
"virtual": { "virtual": {
"loadFailed": "Échec du chargement des éléments", "loadFailed": "Échec du chargement des éléments",
"loadMoreFailed": "Échec du chargement de plus d'éléments", "loadMoreFailed": "Échec du chargement de plus d'éléments",
@@ -2129,6 +2233,7 @@
"fileRenameFailed": "Échec du renommage du fichier : {error}", "fileRenameFailed": "Échec du renommage du fichier : {error}",
"previewUpdated": "Aperçu mis à jour avec succès", "previewUpdated": "Aperçu mis à jour avec succès",
"previewUploadFailed": "Échec du téléchargement de l'image d'aperçu", "previewUploadFailed": "Échec du téléchargement de l'image d'aperçu",
"previewDropInvalid": "Type de fichier non pris en charge : {name}. Déposez plutôt une image ou une vidéo MP4.",
"refreshComplete": "{action} terminé", "refreshComplete": "{action} terminé",
"refreshFailed": "Échec de {action} des {type}s", "refreshFailed": "Échec de {action} des {type}s",
"metadataRefreshed": "Métadonnées actualisées avec succès", "metadataRefreshed": "Métadonnées actualisées avec succès",
+111 -6
View File
@@ -186,6 +186,16 @@
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.", "cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
"error": "תיקון המתכונים נכשל: {message}" "error": "תיקון המתכונים נכשל: {message}"
}, },
"rematchRecipes": {
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "ניהול מודלים מוחרגים" "label": "ניהול מודלים מוחרגים"
}, },
@@ -449,6 +459,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)" "compact": "7 (1080p), 8 (2K), 10 (4K)"
}, },
"displayDensityWarning": "אזהרה: צפיפויות גבוהות יותר עלולות לגרום לבעיות ביצועים במערכות עם משאבים מוגבלים.", "displayDensityWarning": "אזהרה: צפיפויות גבוהות יותר עלולות לגרום לבעיות ביצועים במערכות עם משאבים מוגבלים.",
"recipesLayout": "פריסת מתכונים",
"recipesLayoutHelp": "בחר כיצד יסודרו כרטיסי המתכונים: רשת אחידה או פריסת Masonry (בסגנון Pinterest) השומרת על יחס הגובה-רוחב של כל תמונה.",
"recipesLayoutOptions": {
"grid": "רשת",
"masonry": "Masonry"
},
"showFolderSidebar": "הצג סרגל צד תיקיות", "showFolderSidebar": "הצג סרגל צד תיקיות",
"showFolderSidebarHelp": "הפעל או כבה את סרגל הצד לניווט תיקיות בדפי המודל. כאשר הוא כבוי, סרגל הצד ואזור הריחוף נשארים מוסתרים.", "showFolderSidebarHelp": "הפעל או כבה את סרגל הצד לניווט תיקיות בדפי המודל. כאשר הוא כבוי, סרגל הצד ואזור הריחוף נשארים מוסתרים.",
"cardInfoDisplay": "תצוגת מידע בכרטיס", "cardInfoDisplay": "תצוגת מידע בכרטיס",
@@ -606,6 +622,10 @@
"label": "הסתר עדכוני גישה מוקדמת", "label": "הסתר עדכוני גישה מוקדמת",
"help": "רק עדכוני גישה מוקדמת" "help": "רק עדכוני גישה מוקדמת"
}, },
"hidePaidUpdates": {
"label": "הסתר עדכונים בתשלום",
"help": "כשאפשרות זו מופעלת, מודלים עם עדכונים בתשלום בלבד לא יציגו את תגית 'עדכון זמין'"
},
"licenseIcons": { "licenseIcons": {
"useNewStyle": "השתמש בסמלי רישיון מעודכנים", "useNewStyle": "השתמש בסמלי רישיון מעודכנים",
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI." "useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
@@ -678,6 +698,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "מותאם אישית (תואם OpenAI)" "custom": "מותאם אישית (תואם OpenAI)"
}, },
@@ -761,6 +782,7 @@
"copyAll": "העתק את כל התחבירים", "copyAll": "העתק את כל התחבירים",
"refreshAll": "רענן את כל המטא-דאטה", "refreshAll": "רענן את כל המטא-דאטה",
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים", "repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
"reimportMetadata": "ייבא מחדש ממקור", "reimportMetadata": "ייבא מחדש ממקור",
"checkUpdates": "בדוק עדכונים לבחירה", "checkUpdates": "בדוק עדכונים לבחירה",
"moveAll": "העבר הכל לתיקייה", "moveAll": "העבר הכל לתיקייה",
@@ -816,6 +838,7 @@
"setContentRating": "הגדר דירוג תוכן", "setContentRating": "הגדר דירוג תוכן",
"moveToFolder": "העבר לתיקייה", "moveToFolder": "העבר לתיקייה",
"repairMetadata": "תיקון מטא-דאטה", "repairMetadata": "תיקון מטא-דאטה",
"rematchMetadata": "התאמה מחדש למודלים מקומיים",
"reimportMetadata": "ייבא מחדש ממקור", "reimportMetadata": "ייבא מחדש ממקור",
"excludeModel": "החרג מודל", "excludeModel": "החרג מודל",
"restoreModel": "שחזור מודל", "restoreModel": "שחזור מודל",
@@ -830,7 +853,8 @@
"recipes": { "recipes": {
"title": "מתכוני LoRA", "title": "מתכוני LoRA",
"actions": { "actions": {
"sendCheckpoint": "שלח ל-ComfyUI" "sendCheckpoint": "שלח ל-ComfyUI",
"sendRecipe": "שלח ל-ComfyUI"
}, },
"controls": { "controls": {
"import": { "import": {
@@ -901,7 +925,9 @@
"dateAsc": "הכי ישן", "dateAsc": "הכי ישן",
"lorasCount": "מספר LoRAs", "lorasCount": "מספר LoRAs",
"lorasCountDesc": "הכי הרבה", "lorasCountDesc": "הכי הרבה",
"lorasCountAsc": "הכי פחות" "lorasCountAsc": "הכי פחות",
"opened": "נפתחו לאחרונה",
"openedDesc": "נפתחו לאחרונה"
}, },
"refresh": { "refresh": {
"title": "רענן רשימת מתכונים", "title": "רענן רשימת מתכונים",
@@ -912,12 +938,25 @@
"favorites": { "favorites": {
"title": "הצג מועדפים בלבד", "title": "הצג מועדפים בלבד",
"action": "מועדפים" "action": "מועדפים"
},
"layout": {
"title": "פריסת מתכונים",
"grid": "פריסת רשת",
"masonry": "פריסת Masonry (בסגנון Pinterest, שומרת על יחס הגובה-רוחב של התמונה)"
} }
}, },
"duplicates": { "duplicates": {
"found": "נמצאו {count} קבוצות כפולות", "found": "נמצאו {count} קבוצות כפולות",
"noGroups": "לא נמצאו קבוצות כפולות לפי קריטריון ההתאמה הנוכחי",
"keepLatest": "שמור גרסאות אחרונות", "keepLatest": "שמור גרסאות אחרונות",
"deleteSelected": "מחק נבחרים" "deleteSelected": "מחק נבחרים",
"includePromptLabel": "כלול הנחיה בהתאמה",
"basis": {
"loraCombo": "התאמה לפי: שילוב LoRA",
"loraComboAndPrompt": "התאמה לפי: שילוב LoRA + הנחיה",
"hintLoraCombo": "מתכונים עם אותם LoRAs בעוצמות זהות מקובצים יחד.",
"hintPromptIncluded": "מתכונים מקובצים רק כאשר הם משתמשים באותם LoRAs בעוצמות זהות ויש להם אותה הנחיה."
}
}, },
"contextMenu": { "contextMenu": {
"copyRecipe": { "copyRecipe": {
@@ -1205,11 +1244,13 @@
"downloaded": "הורד", "downloaded": "הורד",
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.", "downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
"alreadyInLibrary": "כבר בספרייה", "alreadyInLibrary": "כבר בספרייה",
"partiallyDownloaded": "הורד חלקית",
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]", "autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
"fileSelection": { "fileSelection": {
"title": "בחר פורמט קובץ", "title": "בחר פורמט קובץ",
"files": "קבצים", "files": "קבצים",
"select": "בחר קובץ" "select": "בחר קובץ",
"inLibrary": "בספרייה"
}, },
"errors": { "errors": {
"invalidUrl": "פורמט URL של Civitai לא חוקי", "invalidUrl": "פורמט URL של Civitai לא חוקי",
@@ -1250,8 +1291,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "מפנה {size}",
"title": "מחק מודל", "title": "מחק מודל",
"message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?" "message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?",
"recoverableWarning": "הקובץ יימחק לצמיתות לאחר 20 שניות, אלא אם תבטלו את הפעולה."
},
"deleteRecipe": {
"recoverableWarning": "ניתן לבטל פעולה זו תוך 20 שניות."
}, },
"excludeModel": { "excludeModel": {
"title": "החרג מודל", "title": "החרג מודל",
@@ -1489,6 +1535,30 @@
"examples": "טוען דוגמאות...", "examples": "טוען דוגמאות...",
"versions": "טוען גרסאות..." "versions": "טוען גרסאות..."
}, },
"showcase": {
"hiddenBySfw": "{count} הוסתרו עקב הגדרת SFW בלבד",
"showExamples": "הצג דוגמאות",
"showCount": "הצג דוגמאות ({count})",
"hideExamples": "הסתר דוגמאות",
"addExamples": "הוסף דוגמאות",
"previousExample": "דוגמה קודמת",
"nextExample": "דוגמה הבאה",
"noExamples": "אין תמונות דוגמה זמינות",
"addMoreExamples": "הוסף עוד דוגמאות",
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
"or": "או",
"selectFiles": "בחר קבצים",
"supportedFormats": "פורמטים נתמכים: jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "מייבא קבצים...",
"noSupportedFiles": "לא נבחרו קבצים נתמכים. בחר קבצי תמונה או וידאו.",
"allFiltered": "כל תמונות הדוגמה מסוננות עקב הגדרות תוכן NSFW",
"sfwOnlyEnabled": "ההגדרות שלך מוגדרות כעת להציג רק תוכן SFW",
"changeInSettings": "ניתן לשנות זאת בהגדרות",
"nsfwMature": "תוכן למבוגרים",
"nsfwR": "תוכן בדירוג R",
"nsfwX": "תוכן בדירוג X",
"nsfwXxx": "תוכן בדירוג XXX"
},
"versions": { "versions": {
"heading": "גרסאות המודל", "heading": "גרסאות המודל",
"copy": "נהל את כל הגרסאות של המודל הזה במקום אחד.", "copy": "נהל את כל הגרסאות של המודל הזה במקום אחד.",
@@ -1516,6 +1586,8 @@
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך", "newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
"earlyAccess": "גישה מוקדמת", "earlyAccess": "גישה מוקדמת",
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai", "earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
"paid": "בתשלום",
"paidTooltip": "גרסה זו דורשת תשלום כדי להוריד",
"ignored": "התעלם", "ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו", "ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
"onSiteOnly": "רק באתר", "onSiteOnly": "רק באתר",
@@ -1524,7 +1596,9 @@
"actions": { "actions": {
"download": "הורדה", "download": "הורדה",
"downloadTooltip": "הורד את הגרסה הזו", "downloadTooltip": "הורד את הגרסה הזו",
"downloadRemainingTooltip": "הורד את הקבצים הנותרים של גרסה זו",
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai", "downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
"downloadPaidTooltip": "הורד את הגרסה בתשלום הזו מ-Civitai",
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai", "downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
"delete": "מחיקה", "delete": "מחיקה",
"deleteTooltip": "מחק את הגרסה המקומית הזו", "deleteTooltip": "מחק את הגרסה המקומית הזו",
@@ -1581,6 +1655,21 @@
"downloadCsv": "הורד CSV", "downloadCsv": "הורד CSV",
"columnModelName": "שם המודל", "columnModelName": "שם המודל",
"columnError": "שגיאה" "columnError": "שגיאה"
},
"downloadBatchSummary": {
"title": "סיכום הורדה בכמות",
"statSuccess": "הצליחו",
"statFailed": "נכשלו",
"statTotal": "סה\"כ",
"successMessage": "כל {count} הדגמים הורדו בהצלחה",
"completedWithErrors": "הושלם עם שגיאות",
"failed": "ההורדה נכשלה",
"failedItems": "פריטים שנכשלו ({count})",
"columnName": "שם הדגם",
"columnError": "שגיאה",
"close": "סגור",
"copyReport": "העתק דוח",
"retryFailed": "נסה שוב ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -1679,6 +1768,7 @@
"recipeReplaced": "מתכון הוחלף ב-workflow", "recipeReplaced": "מתכון הוחלף ב-workflow",
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה", "recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי", "noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noPromptTargets": "אין יעדי הנחיה תואמים ב-workflow.\nלחץ לחיצה ימנית על צומת ב-ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "לא נבחר צומת יעד", "noTargetNodeSelected": "לא נבחר צומת יעד",
"modelUpdated": "מודל עודכן ב-workflow", "modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל", "modelFailed": "עדכון צומת המודל נכשל",
@@ -1855,6 +1945,7 @@
"downloadPartialSuccess": "הורדו {completed} מתוך {total} LoRAs", "downloadPartialSuccess": "הורדו {completed} מתוך {total} LoRAs",
"downloadPartialWithAccess": "הורדו {completed} מתוך {total} LoRAs. {accessFailures} נכשלו עקב הגבלות גישה. בדוק את מפתח ה-API שלך בהגדרות או את סטטוס הגישה המוקדמת.", "downloadPartialWithAccess": "הורדו {completed} מתוך {total} LoRAs. {accessFailures} נכשלו עקב הגבלות גישה. בדוק את מפתח ה-API שלך בהגדרות או את סטטוס הגישה המוקדמת.",
"pleaseSelectVersion": "אנא בחר גרסה", "pleaseSelectVersion": "אנא בחר גרסה",
"pleaseSelectFile": "אנא בחר לפחות קובץ אחד",
"versionExists": "גרסה זו כבר קיימת בספרייה שלך", "versionExists": "גרסה זו כבר קיימת בספרייה שלך",
"downloadCompleted": "ההורדה הושלמה בהצלחה", "downloadCompleted": "ההורדה הושלמה בהצלחה",
"downloadSkippedByBaseModel": "ההורדה דולגה כי מודל הבסיס {baseModel} מוחרג", "downloadSkippedByBaseModel": "ההורדה דולגה כי מודל הבסיס {baseModel} מוחרג",
@@ -1929,6 +2020,12 @@
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})", "repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים", "repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}", "repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
"reimporting": "מייבא מתכון מחדש מהמקור...", "reimporting": "מייבא מתכון מחדש מהמקור...",
"reimportSuccess": "המתכון יובא מחדש בהצלחה", "reimportSuccess": "המתכון יובא מחדש בהצלחה",
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})", "reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
@@ -2037,7 +2134,6 @@
"presetNameTooLong": "שם קביעה מראש חייב להיות {max} תווים או פחות", "presetNameTooLong": "שם קביעה מראש חייב להיות {max} תווים או פחות",
"presetNameInvalidChars": "שם קביעה מראש מכיל תווים לא חוקיים", "presetNameInvalidChars": "שם קביעה מראש מכיל תווים לא חוקיים",
"presetNameExists": "קביעה מראש עם שם זה כבר קיימת", "presetNameExists": "קביעה מראש עם שם זה כבר קיימת",
"maxPresetsReached": "מותר מקסימום {max} קביעות מראש. מחק אחת כדי להוסיף עוד.",
"presetNotFound": "קביעה מראש לא נמצאה", "presetNotFound": "קביעה מראש לא נמצאה",
"invalidPreset": "נתוני קביעה מראש לא חוקיים", "invalidPreset": "נתוני קביעה מראש לא חוקיים",
"deletePresetFailed": "מחיקת קביעה מראש נכשלה", "deletePresetFailed": "מחיקת קביעה מראש נכשלה",
@@ -2066,6 +2162,14 @@
"updateFailed": "עדכון מילות הטריגר נכשל", "updateFailed": "עדכון מילות הטריגר נכשל",
"copyFailed": "ההעתקה נכשלה" "copyFailed": "ההעתקה נכשלה"
}, },
"undo": {
"action": "בטל",
"deleted": "נמחק: {name}",
"deletedBulk": "{count} פריטים נמחקו",
"expired": "חלון הביטול פג. הפריט נמחק לצמיתות.",
"failed": "הביטול נכשל: {error}",
"restored": "הפריט שוחזר"
},
"virtual": { "virtual": {
"loadFailed": "טעינת הפריטים נכשלה", "loadFailed": "טעינת הפריטים נכשלה",
"loadMoreFailed": "טעינת פריטים נוספים נכשלה", "loadMoreFailed": "טעינת פריטים נוספים נכשלה",
@@ -2129,6 +2233,7 @@
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}", "fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה", "previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",
"previewUploadFailed": "העלאת תמונת התצוגה המקדימה נכשלה", "previewUploadFailed": "העלאת תמונת התצוגה המקדימה נכשלה",
"previewDropInvalid": "סוג קובץ לא נתמך: {name}. גרור במקום זאת תמונה או סרטון MP4.",
"refreshComplete": "{action} הושלם", "refreshComplete": "{action} הושלם",
"refreshFailed": "{action} של {type}s נכשל", "refreshFailed": "{action} של {type}s נכשל",
"metadataRefreshed": "המטא-דאטה רועננה בהצלחה", "metadataRefreshed": "המטא-דאטה רועננה בהצלחה",
+111 -6
View File
@@ -186,6 +186,16 @@
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。", "cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
"error": "レシピの修復に失敗しました: {message}" "error": "レシピの修復に失敗しました: {message}"
}, },
"rematchRecipes": {
"label": "レシピをローカルモデルに再マッチング",
"loading": "レシピをローカルモデルに再マッチングしています...",
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
"error": "レシピの再マッチングに失敗しました:{message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "除外モデルを管理" "label": "除外モデルを管理"
}, },
@@ -449,6 +459,12 @@
"compact": "71080p)、82K)、104K" "compact": "71080p)、82K)、104K"
}, },
"displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。", "displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。",
"recipesLayout": "レシピのレイアウト",
"recipesLayoutHelp": "レシピカードの配置方法を選択:均一なグリッド、または各画像のアスペクト比を保持するメイソンリー(Pinterest スタイル)レイアウト。",
"recipesLayoutOptions": {
"grid": "グリッド",
"masonry": "メイソンリー"
},
"showFolderSidebar": "フォルダサイドバーを表示", "showFolderSidebar": "フォルダサイドバーを表示",
"showFolderSidebarHelp": "モデルページのフォルダナビゲーションサイドバーを表示/非表示にします。無効にするとサイドバーとホバーエリアは表示されません。", "showFolderSidebarHelp": "モデルページのフォルダナビゲーションサイドバーを表示/非表示にします。無効にするとサイドバーとホバーエリアは表示されません。",
"cardInfoDisplay": "カード情報表示", "cardInfoDisplay": "カード情報表示",
@@ -606,6 +622,10 @@
"label": "早期アクセス更新を非表示", "label": "早期アクセス更新を非表示",
"help": "早期アクセスのみの更新" "help": "早期アクセスのみの更新"
}, },
"hidePaidUpdates": {
"label": "有料更新を非表示",
"help": "有効にすると、有料の更新のみがあるモデルには「更新あり」バッジが表示されません"
},
"licenseIcons": { "licenseIcons": {
"useNewStyle": "更新されたライセンスアイコンを使用", "useNewStyle": "更新されたライセンスアイコンを使用",
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。" "useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
@@ -678,6 +698,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "カスタム(OpenAI 互換)" "custom": "カスタム(OpenAI 互換)"
}, },
@@ -761,6 +782,7 @@
"copyAll": "すべての構文をコピー", "copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新", "refreshAll": "すべてのメタデータを更新",
"repairMetadata": "選択したレシピのメタデータを修復", "repairMetadata": "選択したレシピのメタデータを修復",
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
"reimportMetadata": "ソースから再インポート", "reimportMetadata": "ソースから再インポート",
"checkUpdates": "選択項目の更新を確認", "checkUpdates": "選択項目の更新を確認",
"moveAll": "すべてをフォルダに移動", "moveAll": "すべてをフォルダに移動",
@@ -816,6 +838,7 @@
"setContentRating": "コンテンツレーティングを設定", "setContentRating": "コンテンツレーティングを設定",
"moveToFolder": "フォルダに移動", "moveToFolder": "フォルダに移動",
"repairMetadata": "メタデータを修復", "repairMetadata": "メタデータを修復",
"rematchMetadata": "ローカルモデルに再マッチング",
"reimportMetadata": "ソースから再インポート", "reimportMetadata": "ソースから再インポート",
"excludeModel": "モデルを除外", "excludeModel": "モデルを除外",
"restoreModel": "モデルを復元", "restoreModel": "モデルを復元",
@@ -830,7 +853,8 @@
"recipes": { "recipes": {
"title": "LoRAレシピ", "title": "LoRAレシピ",
"actions": { "actions": {
"sendCheckpoint": "ComfyUIへ送信" "sendCheckpoint": "ComfyUIへ送信",
"sendRecipe": "ComfyUIへ送信"
}, },
"controls": { "controls": {
"import": { "import": {
@@ -901,7 +925,9 @@
"dateAsc": "古い順", "dateAsc": "古い順",
"lorasCount": "LoRA数", "lorasCount": "LoRA数",
"lorasCountDesc": "多い順", "lorasCountDesc": "多い順",
"lorasCountAsc": "少ない順" "lorasCountAsc": "少ない順",
"opened": "最近開いた",
"openedDesc": "最近開いた"
}, },
"refresh": { "refresh": {
"title": "レシピリストを更新", "title": "レシピリストを更新",
@@ -912,12 +938,25 @@
"favorites": { "favorites": {
"title": "お気に入りのみ表示", "title": "お気に入りのみ表示",
"action": "お気に入り" "action": "お気に入り"
},
"layout": {
"title": "レシピのレイアウト",
"grid": "グリッドレイアウト",
"masonry": "メイソンリーレイアウト(Pinterest スタイル、画像のアスペクト比を保持)"
} }
}, },
"duplicates": { "duplicates": {
"found": "{count} 個の重複グループが見つかりました", "found": "{count} 個の重複グループが見つかりました",
"noGroups": "現在の一致基準では重複グループが見つかりませんでした",
"keepLatest": "最新バージョンを保持", "keepLatest": "最新バージョンを保持",
"deleteSelected": "選択したものを削除" "deleteSelected": "選択したものを削除",
"includePromptLabel": "一致判定にプロンプトを含める",
"basis": {
"loraCombo": "一致基準: LoRA の組み合わせ",
"loraComboAndPrompt": "一致基準: LoRA の組み合わせ + プロンプト",
"hintLoraCombo": "同じ LoRA を同じ強度で使用するレシピがグループ化されます。",
"hintPromptIncluded": "レシピは、同じ LoRA を同じ強度で使用し、かつプロンプトが同じ場合にのみグループ化されます。"
}
}, },
"contextMenu": { "contextMenu": {
"copyRecipe": { "copyRecipe": {
@@ -1205,11 +1244,13 @@
"downloaded": "ダウンロード済み", "downloaded": "ダウンロード済み",
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。", "downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
"alreadyInLibrary": "既にライブラリ内", "alreadyInLibrary": "既にライブラリ内",
"partiallyDownloaded": "一部ダウンロード済み",
"autoOrganizedPath": "[パステンプレートによる自動整理]", "autoOrganizedPath": "[パステンプレートによる自動整理]",
"fileSelection": { "fileSelection": {
"title": "ファイル形式を選択", "title": "ファイル形式を選択",
"files": "ファイル", "files": "ファイル",
"select": "ファイルを選択" "select": "ファイルを選択",
"inLibrary": "ライブラリ内"
}, },
"errors": { "errors": {
"invalidUrl": "無効なCivitai URL形式", "invalidUrl": "無効なCivitai URL形式",
@@ -1250,8 +1291,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "{size} を解放します",
"title": "モデルを削除", "title": "モデルを削除",
"message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?" "message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?",
"recoverableWarning": "元に戻さない場合、このファイルは20秒後に完全に削除されます。"
},
"deleteRecipe": {
"recoverableWarning": "この操作は20秒以内であれば元に戻せます。"
}, },
"excludeModel": { "excludeModel": {
"title": "モデルを除外", "title": "モデルを除外",
@@ -1489,6 +1535,30 @@
"examples": "例を読み込み中...", "examples": "例を読み込み中...",
"versions": "バージョンを読み込み中..." "versions": "バージョンを読み込み中..."
}, },
"showcase": {
"hiddenBySfw": "SFWのみ設定により{count}件非表示",
"showExamples": "例を表示",
"showCount": "例を表示({count}",
"hideExamples": "例を非表示",
"addExamples": "例を追加",
"previousExample": "前の例",
"nextExample": "次の例",
"noExamples": "利用可能な例画像がありません",
"addMoreExamples": "さらに例を追加",
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
"or": "または",
"selectFiles": "ファイルを選択",
"supportedFormats": "対応形式:jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "ファイルをインポート中...",
"noSupportedFiles": "対応ファイルが選択されていません。画像または動画ファイルを選択してください。",
"allFiltered": "NSFWコンテンツ設定により、すべての例画像がフィルタリングされています",
"sfwOnlyEnabled": "現在の設定ではSFWコンテンツのみが表示されます",
"changeInSettings": "設定から変更できます",
"nsfwMature": "成人向けコンテンツ",
"nsfwR": "R指定コンテンツ",
"nsfwX": "X指定コンテンツ",
"nsfwXxx": "XXX指定コンテンツ"
},
"versions": { "versions": {
"heading": "モデルバージョン", "heading": "モデルバージョン",
"copy": "このモデルのすべてのバージョンを一か所で管理します。", "copy": "このモデルのすべてのバージョンを一か所で管理します。",
@@ -1516,6 +1586,8 @@
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです", "newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
"earlyAccess": "早期アクセス", "earlyAccess": "早期アクセス",
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です", "earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
"paid": "有料",
"paidTooltip": "このバージョンのダウンロードには支払いが必要です",
"ignored": "無視中", "ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です", "ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ", "onSiteOnly": "サイト内のみ",
@@ -1524,7 +1596,9 @@
"actions": { "actions": {
"download": "ダウンロード", "download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード", "downloadTooltip": "このバージョンをダウンロード",
"downloadRemainingTooltip": "このバージョンの残りのファイルをダウンロード",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード", "downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"downloadPaidTooltip": "Civitai からこの有料バージョンをダウンロード",
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません", "downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
"delete": "削除", "delete": "削除",
"deleteTooltip": "このローカルバージョンを削除", "deleteTooltip": "このローカルバージョンを削除",
@@ -1581,6 +1655,21 @@
"downloadCsv": "CSVをダウンロード", "downloadCsv": "CSVをダウンロード",
"columnModelName": "モデル名", "columnModelName": "モデル名",
"columnError": "エラー" "columnError": "エラー"
},
"downloadBatchSummary": {
"title": "バッチダウンロードの概要",
"statSuccess": "成功",
"statFailed": "失敗",
"statTotal": "合計",
"successMessage": "{count} 個のモデルがすべて正常にダウンロードされました",
"completedWithErrors": "エラーありで完了",
"failed": "ダウンロードに失敗しました",
"failedItems": "失敗した項目({count}",
"columnName": "モデル名",
"columnError": "エラー",
"close": "閉じる",
"copyReport": "レポートをコピー",
"retryFailed": "失敗した項目を再試行({count}"
} }
}, },
"modelTags": { "modelTags": {
@@ -1679,6 +1768,7 @@
"recipeReplaced": "レシピがワークフローで置換されました", "recipeReplaced": "レシピがワークフローで置換されました",
"recipeFailedToSend": "レシピをワークフローに送信できませんでした", "recipeFailedToSend": "レシピをワークフローに送信できませんでした",
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません", "noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noPromptTargets": "ワークフロー内に互換性のあるプロンプトターゲットがありません。\nComfyUIでノードを右クリック → Mark as → Send Prompt Target",
"noTargetNodeSelected": "ターゲットノードが選択されていません", "noTargetNodeSelected": "ターゲットノードが選択されていません",
"modelUpdated": "モデルがワークフローで更新されました", "modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました", "modelFailed": "モデルノードの更新に失敗しました",
@@ -1855,6 +1945,7 @@
"downloadPartialSuccess": "{total} LoRAのうち {completed} がダウンロードされました", "downloadPartialSuccess": "{total} LoRAのうち {completed} がダウンロードされました",
"downloadPartialWithAccess": "{total} LoRAのうち {completed} がダウンロードされました。{accessFailures} はアクセス制限により失敗しました。設定でAPIキーまたはアーリーアクセス状況を確認してください。", "downloadPartialWithAccess": "{total} LoRAのうち {completed} がダウンロードされました。{accessFailures} はアクセス制限により失敗しました。設定でAPIキーまたはアーリーアクセス状況を確認してください。",
"pleaseSelectVersion": "バージョンを選択してください", "pleaseSelectVersion": "バージョンを選択してください",
"pleaseSelectFile": "ファイルを1つ以上選択してください",
"versionExists": "このバージョンは既にライブラリに存在します", "versionExists": "このバージョンは既にライブラリに存在します",
"downloadCompleted": "ダウンロードが正常に完了しました", "downloadCompleted": "ダウンロードが正常に完了しました",
"downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました", "downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました",
@@ -1929,6 +2020,12 @@
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)", "repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です", "repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}", "repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
"reimporting": "ソースからレシピを再インポート中...", "reimporting": "ソースからレシピを再インポート中...",
"reimportSuccess": "レシピの再インポートが完了しました", "reimportSuccess": "レシピの再インポートが完了しました",
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)", "reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
@@ -2037,7 +2134,6 @@
"presetNameTooLong": "プリセット名は{max}文字以内にしてください", "presetNameTooLong": "プリセット名は{max}文字以内にしてください",
"presetNameInvalidChars": "プリセット名に使用できない文字が含まれています", "presetNameInvalidChars": "プリセット名に使用できない文字が含まれています",
"presetNameExists": "同じ名前のプリセットが既に存在します", "presetNameExists": "同じ名前のプリセットが既に存在します",
"maxPresetsReached": "プリセットは最大{max}個までです。追加するには既存のものを削除してください。",
"presetNotFound": "プリセットが見つかりません", "presetNotFound": "プリセットが見つかりません",
"invalidPreset": "無効なプリセットデータです", "invalidPreset": "無効なプリセットデータです",
"deletePresetFailed": "プリセットの削除に失敗しました", "deletePresetFailed": "プリセットの削除に失敗しました",
@@ -2066,6 +2162,14 @@
"updateFailed": "トリガーワードの更新に失敗しました", "updateFailed": "トリガーワードの更新に失敗しました",
"copyFailed": "コピーに失敗しました" "copyFailed": "コピーに失敗しました"
}, },
"undo": {
"action": "元に戻す",
"deleted": "{name} を削除しました",
"deletedBulk": "{count} 個のアイテムを削除しました",
"expired": "元に戻せる時間が経過しました。アイテムは完全に削除されました。",
"failed": "元に戻せませんでした: {error}",
"restored": "アイテムを復元しました"
},
"virtual": { "virtual": {
"loadFailed": "アイテムの読み込みに失敗しました", "loadFailed": "アイテムの読み込みに失敗しました",
"loadMoreFailed": "追加アイテムの読み込みに失敗しました", "loadMoreFailed": "追加アイテムの読み込みに失敗しました",
@@ -2129,6 +2233,7 @@
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}", "fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
"previewUpdated": "プレビューが正常に更新されました", "previewUpdated": "プレビューが正常に更新されました",
"previewUploadFailed": "プレビュー画像のアップロードに失敗しました", "previewUploadFailed": "プレビュー画像のアップロードに失敗しました",
"previewDropInvalid": "サポートされていないファイル形式:{name}。画像またはMP4ビデオをドロップしてください。",
"refreshComplete": "{action} 完了", "refreshComplete": "{action} 完了",
"refreshFailed": "{type}の{action}に失敗しました", "refreshFailed": "{type}の{action}に失敗しました",
"metadataRefreshed": "メタデータが正常に更新されました", "metadataRefreshed": "メタデータが正常に更新されました",
+111 -6
View File
@@ -186,6 +186,16 @@
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.", "cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
"error": "레시피 복구 실패: {message}" "error": "레시피 복구 실패: {message}"
}, },
"rematchRecipes": {
"label": "레시피를 로컬 모델에 다시 매칭",
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
"error": "레시피 재매칭 실패: {message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "제외된 모델 관리" "label": "제외된 모델 관리"
}, },
@@ -449,6 +459,12 @@
"compact": "7개 (1080p), 8개 (2K), 10개 (4K)" "compact": "7개 (1080p), 8개 (2K), 10개 (4K)"
}, },
"displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.", "displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.",
"recipesLayout": "레시피 레이아웃",
"recipesLayoutHelp": "레시피 카드의 배열 방식을 선택하세요: 균일한 그리드 또는 각 이미지의 종횡비를 유지하는 메이슨리(Pinterest 스타일) 레이아웃.",
"recipesLayoutOptions": {
"grid": "그리드",
"masonry": "메이슨리"
},
"showFolderSidebar": "폴더 사이드바 표시", "showFolderSidebar": "폴더 사이드바 표시",
"showFolderSidebarHelp": "모델 페이지에서 폴더 탐색 사이드바를 켜거나 끕니다. 비활성화하면 사이드바와 호버 영역이 표시되지 않습니다.", "showFolderSidebarHelp": "모델 페이지에서 폴더 탐색 사이드바를 켜거나 끕니다. 비활성화하면 사이드바와 호버 영역이 표시되지 않습니다.",
"cardInfoDisplay": "카드 정보 표시", "cardInfoDisplay": "카드 정보 표시",
@@ -606,6 +622,10 @@
"label": "얼리 액세스 업데이트 숨기기", "label": "얼리 액세스 업데이트 숨기기",
"help": "얼리 액세스 업데이트만" "help": "얼리 액세스 업데이트만"
}, },
"hidePaidUpdates": {
"label": "유료 업데이트 숨기기",
"help": "활성화하면 유료 업데이트만 있는 모델에 '업데이트 가능' 배지가 표시되지 않습니다"
},
"licenseIcons": { "licenseIcons": {
"useNewStyle": "업데이트된 라이선스 아이콘 사용", "useNewStyle": "업데이트된 라이선스 아이콘 사용",
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다." "useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
@@ -678,6 +698,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "사용자 정의 (OpenAI 호환)" "custom": "사용자 정의 (OpenAI 호환)"
}, },
@@ -761,6 +782,7 @@
"copyAll": "모든 문법 복사", "copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침", "refreshAll": "모든 메타데이터 새로고침",
"repairMetadata": "선택한 레시피 메타데이터 복구", "repairMetadata": "선택한 레시피 메타데이터 복구",
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
"reimportMetadata": "소스에서 다시 가져오기", "reimportMetadata": "소스에서 다시 가져오기",
"checkUpdates": "선택 항목 업데이트 확인", "checkUpdates": "선택 항목 업데이트 확인",
"moveAll": "모두 폴더로 이동", "moveAll": "모두 폴더로 이동",
@@ -816,6 +838,7 @@
"setContentRating": "콘텐츠 등급 설정", "setContentRating": "콘텐츠 등급 설정",
"moveToFolder": "폴더로 이동", "moveToFolder": "폴더로 이동",
"repairMetadata": "메타데이터 복구", "repairMetadata": "메타데이터 복구",
"rematchMetadata": "로컬 모델에 다시 매칭",
"reimportMetadata": "소스에서 다시 가져오기", "reimportMetadata": "소스에서 다시 가져오기",
"excludeModel": "모델 제외", "excludeModel": "모델 제외",
"restoreModel": "모델 복원", "restoreModel": "모델 복원",
@@ -830,7 +853,8 @@
"recipes": { "recipes": {
"title": "LoRA 레시피", "title": "LoRA 레시피",
"actions": { "actions": {
"sendCheckpoint": "ComfyUI로 보내기" "sendCheckpoint": "ComfyUI로 보내기",
"sendRecipe": "ComfyUI로 보내기"
}, },
"controls": { "controls": {
"import": { "import": {
@@ -901,7 +925,9 @@
"dateAsc": "오래된순", "dateAsc": "오래된순",
"lorasCount": "LoRA 수", "lorasCount": "LoRA 수",
"lorasCountDesc": "많은순", "lorasCountDesc": "많은순",
"lorasCountAsc": "적은순" "lorasCountAsc": "적은순",
"opened": "최근에 연",
"openedDesc": "최근에 연"
}, },
"refresh": { "refresh": {
"title": "레시피 목록 새로고침", "title": "레시피 목록 새로고침",
@@ -912,12 +938,25 @@
"favorites": { "favorites": {
"title": "즐겨찾기만 표시", "title": "즐겨찾기만 표시",
"action": "즐겨찾기" "action": "즐겨찾기"
},
"layout": {
"title": "레시피 레이아웃",
"grid": "그리드 레이아웃",
"masonry": "메이슨리 레이아웃 (Pinterest 스타일, 이미지 종횡비 유지)"
} }
}, },
"duplicates": { "duplicates": {
"found": "{count}개의 중복 그룹 발견", "found": "{count}개의 중복 그룹 발견",
"noGroups": "현재 일치 기준으로 중복 그룹을 찾을 수 없습니다",
"keepLatest": "최신 버전 유지", "keepLatest": "최신 버전 유지",
"deleteSelected": "선택된 항목 삭제" "deleteSelected": "선택된 항목 삭제",
"includePromptLabel": "일치 항목에 프롬프트 포함",
"basis": {
"loraCombo": "일치 기준: LoRA 조합",
"loraComboAndPrompt": "일치 기준: LoRA 조합 + 프롬프트",
"hintLoraCombo": "동일한 LoRA를 동일한 강도로 사용하는 레시피가 그룹화됩니다.",
"hintPromptIncluded": "동일한 LoRA를 동일한 강도로 사용하고 프롬프트도 동일한 경우에만 레시피가 그룹화됩니다."
}
}, },
"contextMenu": { "contextMenu": {
"copyRecipe": { "copyRecipe": {
@@ -1205,11 +1244,13 @@
"downloaded": "다운로드됨", "downloaded": "다운로드됨",
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.", "downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
"alreadyInLibrary": "이미 라이브러리에 있음", "alreadyInLibrary": "이미 라이브러리에 있음",
"partiallyDownloaded": "부분적으로 다운로드됨",
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]", "autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
"fileSelection": { "fileSelection": {
"title": "파일 형식 선택", "title": "파일 형식 선택",
"files": "개 파일", "files": "개 파일",
"select": "파일 선택" "select": "파일 선택",
"inLibrary": "라이브러리에 있음"
}, },
"errors": { "errors": {
"invalidUrl": "잘못된 Civitai URL 형식", "invalidUrl": "잘못된 Civitai URL 형식",
@@ -1250,8 +1291,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "{size} 확보",
"title": "모델 삭제", "title": "모델 삭제",
"message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?" "message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?",
"recoverableWarning": "실행 취소하지 않으면 20초 후에 파일이 영구적으로 삭제됩니다."
},
"deleteRecipe": {
"recoverableWarning": "이 작업은 20초 이내에 실행 취소할 수 있습니다."
}, },
"excludeModel": { "excludeModel": {
"title": "모델 제외", "title": "모델 제외",
@@ -1489,6 +1535,30 @@
"examples": "예시 로딩 중...", "examples": "예시 로딩 중...",
"versions": "버전 로딩 중..." "versions": "버전 로딩 중..."
}, },
"showcase": {
"hiddenBySfw": "SFW 전용 설정으로 {count}개 숨겨짐",
"showExamples": "예시 보기",
"showCount": "예시 보기 ({count})",
"hideExamples": "예시 숨기기",
"addExamples": "예시 추가",
"previousExample": "이전 예시",
"nextExample": "다음 예시",
"noExamples": "사용 가능한 예시 이미지가 없습니다",
"addMoreExamples": "예시 더 추가",
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
"or": "또는",
"selectFiles": "파일 선택",
"supportedFormats": "지원되는 형식: jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "파일을 가져오는 중...",
"noSupportedFiles": "지원되는 파일이 선택되지 않았습니다. 이미지 또는 비디오 파일을 선택하세요.",
"allFiltered": "NSFW 콘텐츠 설정으로 인해 모든 예시 이미지가 필터링되었습니다",
"sfwOnlyEnabled": "현재 설정이 안전한(SFW) 콘텐츠만 표시하도록 설정되어 있습니다",
"changeInSettings": "설정에서 변경할 수 있습니다",
"nsfwMature": "성인 콘텐츠",
"nsfwR": "R등급 콘텐츠",
"nsfwX": "X등급 콘텐츠",
"nsfwXxx": "XXX등급 콘텐츠"
},
"versions": { "versions": {
"heading": "모델 버전", "heading": "모델 버전",
"copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.", "copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.",
@@ -1516,6 +1586,8 @@
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다", "newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
"earlyAccess": "얼리 액세스", "earlyAccess": "얼리 액세스",
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다", "earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
"paid": "유료",
"paidTooltip": "이 버전은 다운로드하려면 결제가 필요합니다",
"ignored": "무시됨", "ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다", "ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
"onSiteOnly": "사이트 내 전용", "onSiteOnly": "사이트 내 전용",
@@ -1524,7 +1596,9 @@
"actions": { "actions": {
"download": "다운로드", "download": "다운로드",
"downloadTooltip": "이 버전 다운로드", "downloadTooltip": "이 버전 다운로드",
"downloadRemainingTooltip": "이 버전의 나머지 파일 다운로드",
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드", "downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
"downloadPaidTooltip": "Civitai에서 이 유료 버전 다운로드",
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다", "downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
"delete": "삭제", "delete": "삭제",
"deleteTooltip": "이 로컬 버전 삭제", "deleteTooltip": "이 로컬 버전 삭제",
@@ -1581,6 +1655,21 @@
"downloadCsv": "CSV 다운로드", "downloadCsv": "CSV 다운로드",
"columnModelName": "모델 이름", "columnModelName": "모델 이름",
"columnError": "오류" "columnError": "오류"
},
"downloadBatchSummary": {
"title": "일괄 다운로드 요약",
"statSuccess": "성공",
"statFailed": "실패",
"statTotal": "전체",
"successMessage": "{count}개 모델이 모두 성공적으로 다운로드되었습니다",
"completedWithErrors": "오류와 함께 완료됨",
"failed": "다운로드 실패",
"failedItems": "실패한 항목 ({count})",
"columnName": "모델 이름",
"columnError": "오류",
"close": "닫기",
"copyReport": "보고서 복사",
"retryFailed": "실패 항목 재시도 ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -1679,6 +1768,7 @@
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다", "recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다", "recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다", "noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noPromptTargets": "워크플로우에 호환되는 프롬프트 타겟이 없습니다.\nComfyUI에서 노드를 우클릭 → Mark as → Send Prompt Target",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다", "noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다", "modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패", "modelFailed": "모델 노드 업데이트 실패",
@@ -1855,6 +1945,7 @@
"downloadPartialSuccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다", "downloadPartialSuccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다",
"downloadPartialWithAccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다. {accessFailures}개는 액세스 제한으로 실패했습니다. 설정에서 API 키 또는 얼리 액세스 상태를 확인하세요.", "downloadPartialWithAccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다. {accessFailures}개는 액세스 제한으로 실패했습니다. 설정에서 API 키 또는 얼리 액세스 상태를 확인하세요.",
"pleaseSelectVersion": "버전을 선택해주세요", "pleaseSelectVersion": "버전을 선택해주세요",
"pleaseSelectFile": "파일을 하나 이상 선택해주세요",
"versionExists": "이 버전은 이미 라이브러리에 있습니다", "versionExists": "이 버전은 이미 라이브러리에 있습니다",
"downloadCompleted": "다운로드가 성공적으로 완료되었습니다", "downloadCompleted": "다운로드가 성공적으로 완료되었습니다",
"downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다", "downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다",
@@ -1929,6 +2020,12 @@
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)", "repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다", "repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
"repairBulkFailed": "선택한 레시피 복구 실패: {message}", "repairBulkFailed": "선택한 레시피 복구 실패: {message}",
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
"reimporting": "소스에서 레시피를 다시 가져오는 중...", "reimporting": "소스에서 레시피를 다시 가져오는 중...",
"reimportSuccess": "레시피를 다시 가져왔습니다", "reimportSuccess": "레시피를 다시 가져왔습니다",
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)", "reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
@@ -2037,7 +2134,6 @@
"presetNameTooLong": "프리셋 이름은 {max}자 이하여야 합니다", "presetNameTooLong": "프리셋 이름은 {max}자 이하여야 합니다",
"presetNameInvalidChars": "프리셋 이름에 유효하지 않은 문자가 포함되어 있습니다", "presetNameInvalidChars": "프리셋 이름에 유효하지 않은 문자가 포함되어 있습니다",
"presetNameExists": "동일한 이름의 프리셋이 이미 존재합니다", "presetNameExists": "동일한 이름의 프리셋이 이미 존재합니다",
"maxPresetsReached": "최대 {max}개의 프리셋만 허용됩니다. 더 추가하려면 기존 것을 삭제하세요.",
"presetNotFound": "프리셋을 찾을 수 없습니다", "presetNotFound": "프리셋을 찾을 수 없습니다",
"invalidPreset": "잘못된 프리셋 데이터입니다", "invalidPreset": "잘못된 프리셋 데이터입니다",
"deletePresetFailed": "프리셋 삭제에 실패했습니다", "deletePresetFailed": "프리셋 삭제에 실패했습니다",
@@ -2066,6 +2162,14 @@
"updateFailed": "트리거 단어 업데이트에 실패했습니다", "updateFailed": "트리거 단어 업데이트에 실패했습니다",
"copyFailed": "복사 실패" "copyFailed": "복사 실패"
}, },
"undo": {
"action": "실행 취소",
"deleted": "{name} 삭제됨",
"deletedBulk": "{count}개 항목 삭제됨",
"expired": "실행 취소 기간이 만료되었습니다. 항목이 영구적으로 삭제되었습니다.",
"failed": "실행 취소 실패: {error}",
"restored": "항목이 복원되었습니다"
},
"virtual": { "virtual": {
"loadFailed": "항목 로딩 실패", "loadFailed": "항목 로딩 실패",
"loadMoreFailed": "더 많은 항목 로딩 실패", "loadMoreFailed": "더 많은 항목 로딩 실패",
@@ -2129,6 +2233,7 @@
"fileRenameFailed": "파일 이름 변경 실패: {error}", "fileRenameFailed": "파일 이름 변경 실패: {error}",
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다", "previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",
"previewUploadFailed": "미리보기 이미지 업로드 실패", "previewUploadFailed": "미리보기 이미지 업로드 실패",
"previewDropInvalid": "지원되지 않는 파일 형식: {name}. 이미지 또는 MP4 동영상을 드롭하세요.",
"refreshComplete": "{action} 완료", "refreshComplete": "{action} 완료",
"refreshFailed": "{type} {action} 실패", "refreshFailed": "{type} {action} 실패",
"metadataRefreshed": "메타데이터가 성공적으로 새로고침되었습니다", "metadataRefreshed": "메타데이터가 성공적으로 새로고침되었습니다",
+111 -6
View File
@@ -186,6 +186,16 @@
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.", "cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
"error": "Ошибка восстановления рецептов: {message}" "error": "Ошибка восстановления рецептов: {message}"
}, },
"rematchRecipes": {
"label": "Повторное сопоставление рецептов с локальными моделями",
"loading": "Повторное сопоставление рецептов с локальными моделями...",
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
"error": "Не удалось выполнить сопоставление рецептов: {message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "Управление исключёнными моделями" "label": "Управление исключёнными моделями"
}, },
@@ -449,6 +459,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)" "compact": "7 (1080p), 8 (2K), 10 (4K)"
}, },
"displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.", "displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.",
"recipesLayout": "Макет рецептов",
"recipesLayoutHelp": "Выберите, как располагаются карточки рецептов: единая сетка или masonry-макет (в стиле Pinterest), сохраняющий пропорции каждого изображения.",
"recipesLayoutOptions": {
"grid": "Сетка",
"masonry": "Masonry"
},
"showFolderSidebar": "Показывать боковую панель папок", "showFolderSidebar": "Показывать боковую панель папок",
"showFolderSidebarHelp": "Включает или выключает боковую панель навигации по папкам на страницах моделей. При отключении панель и область наведения скрыты.", "showFolderSidebarHelp": "Включает или выключает боковую панель навигации по папкам на страницах моделей. При отключении панель и область наведения скрыты.",
"cardInfoDisplay": "Отображение информации карточки", "cardInfoDisplay": "Отображение информации карточки",
@@ -606,6 +622,10 @@
"label": "Скрыть обновления раннего доступа", "label": "Скрыть обновления раннего доступа",
"help": "Только обновления раннего доступа" "help": "Только обновления раннего доступа"
}, },
"hidePaidUpdates": {
"label": "Скрывать платные обновления",
"help": "Если включено, у моделей, для которых доступны только платные обновления, не будет отображаться значок «Доступно обновление»"
},
"licenseIcons": { "licenseIcons": {
"useNewStyle": "Использовать обновлённые значки лицензии", "useNewStyle": "Использовать обновлённые значки лицензии",
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI." "useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
@@ -678,6 +698,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "Пользовательский (совместимый с OpenAI)" "custom": "Пользовательский (совместимый с OpenAI)"
}, },
@@ -761,6 +782,7 @@
"copyAll": "Копировать весь синтаксис", "copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные", "refreshAll": "Обновить все метаданные",
"repairMetadata": "Восстановить метаданные для выбранных", "repairMetadata": "Восстановить метаданные для выбранных",
"rematchMetadata": "Сопоставить выбранные с локальными моделями",
"reimportMetadata": "Переимпортировать из источника", "reimportMetadata": "Переимпортировать из источника",
"checkUpdates": "Проверить обновления для выбранных", "checkUpdates": "Проверить обновления для выбранных",
"moveAll": "Переместить все в папку", "moveAll": "Переместить все в папку",
@@ -816,6 +838,7 @@
"setContentRating": "Установить рейтинг контента", "setContentRating": "Установить рейтинг контента",
"moveToFolder": "Переместить в папку", "moveToFolder": "Переместить в папку",
"repairMetadata": "Восстановить метаданные", "repairMetadata": "Восстановить метаданные",
"rematchMetadata": "Сопоставить с локальными моделями",
"reimportMetadata": "Переимпортировать из источника", "reimportMetadata": "Переимпортировать из источника",
"excludeModel": "Исключить модель", "excludeModel": "Исключить модель",
"restoreModel": "Восстановить модель", "restoreModel": "Восстановить модель",
@@ -830,7 +853,8 @@
"recipes": { "recipes": {
"title": "Рецепты LoRA", "title": "Рецепты LoRA",
"actions": { "actions": {
"sendCheckpoint": "Отправить в ComfyUI" "sendCheckpoint": "Отправить в ComfyUI",
"sendRecipe": "Отправить в ComfyUI"
}, },
"controls": { "controls": {
"import": { "import": {
@@ -901,7 +925,9 @@
"dateAsc": "Сначала старые", "dateAsc": "Сначала старые",
"lorasCount": "Кол-во LoRA", "lorasCount": "Кол-во LoRA",
"lorasCountDesc": "Больше всего", "lorasCountDesc": "Больше всего",
"lorasCountAsc": "Меньше всего" "lorasCountAsc": "Меньше всего",
"opened": "Недавно открытые",
"openedDesc": "Недавно открытые"
}, },
"refresh": { "refresh": {
"title": "Обновить список рецептов", "title": "Обновить список рецептов",
@@ -912,12 +938,25 @@
"favorites": { "favorites": {
"title": "Только избранные", "title": "Только избранные",
"action": "Избранное" "action": "Избранное"
},
"layout": {
"title": "Макет рецептов",
"grid": "Макет сеткой",
"masonry": "Masonry-макет (в стиле Pinterest, сохраняет пропорции изображения)"
} }
}, },
"duplicates": { "duplicates": {
"found": "Найдено {count} групп дубликатов", "found": "Найдено {count} групп дубликатов",
"noGroups": "Дубликатов с текущим критерием не найдено",
"keepLatest": "Оставить последние версии", "keepLatest": "Оставить последние версии",
"deleteSelected": "Удалить выбранные" "deleteSelected": "Удалить выбранные",
"includePromptLabel": "Учитывать запрос при поиске дубликатов",
"basis": {
"loraCombo": "Критерий: комбинация LoRA",
"loraComboAndPrompt": "Критерий: комбинация LoRA + запрос",
"hintLoraCombo": "Рецепты с одинаковыми LoRA и одинаковой силой группируются вместе.",
"hintPromptIncluded": "Рецепты группируются только при одинаковых LoRA с одинаковой силой И одинаковом запросе."
}
}, },
"contextMenu": { "contextMenu": {
"copyRecipe": { "copyRecipe": {
@@ -1205,11 +1244,13 @@
"downloaded": "Загружено", "downloaded": "Загружено",
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.", "downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
"alreadyInLibrary": "Уже в библиотеке", "alreadyInLibrary": "Уже в библиотеке",
"partiallyDownloaded": "Загружено частично",
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]", "autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
"fileSelection": { "fileSelection": {
"title": "Выбрать формат файла", "title": "Выбрать формат файла",
"files": "файлов", "files": "файлов",
"select": "Выбрать файл" "select": "Выбрать файл",
"inLibrary": "В библиотеке"
}, },
"errors": { "errors": {
"invalidUrl": "Неверный формат URL Civitai", "invalidUrl": "Неверный формат URL Civitai",
@@ -1250,8 +1291,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "Освобождает {size}",
"title": "Удалить модель", "title": "Удалить модель",
"message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?" "message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?",
"recoverableWarning": "Файл будет удалён навсегда через 20 секунд, если вы не отмените действие."
},
"deleteRecipe": {
"recoverableWarning": "Это действие можно отменить в течение 20 секунд."
}, },
"excludeModel": { "excludeModel": {
"title": "Исключить модель", "title": "Исключить модель",
@@ -1489,6 +1535,30 @@
"examples": "Загрузка примеров...", "examples": "Загрузка примеров...",
"versions": "Загрузка версий..." "versions": "Загрузка версий..."
}, },
"showcase": {
"hiddenBySfw": "{count} скрыто настройкой «только SFW»",
"showExamples": "Показать примеры",
"showCount": "Показать примеры ({count})",
"hideExamples": "Скрыть примеры",
"addExamples": "Добавить примеры",
"previousExample": "Предыдущий пример",
"nextExample": "Следующий пример",
"noExamples": "Примеры изображений недоступны",
"addMoreExamples": "Добавить ещё примеры",
"dragDrop": "Перетащите изображения или видео сюда",
"or": "или",
"selectFiles": "Выбрать файлы",
"supportedFormats": "Поддерживаемые форматы: jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "Импорт файлов...",
"noSupportedFiles": "Не выбрано поддерживаемых файлов. Пожалуйста, выберите файлы изображений или видео.",
"allFiltered": "Все примеры изображений отфильтрованы из-за настроек NSFW-контента",
"sfwOnlyEnabled": "В настройках сейчас включён показ только безопасного для работы (SFW) контента",
"changeInSettings": "Вы можете изменить это в Настройках",
"nsfwMature": "Контент для взрослых",
"nsfwR": "Контент с рейтингом R",
"nsfwX": "Контент с рейтингом X",
"nsfwXxx": "Контент с рейтингом XXX"
},
"versions": { "versions": {
"heading": "Версии модели", "heading": "Версии модели",
"copy": "Управляйте всеми версиями этой модели в одном месте.", "copy": "Управляйте всеми версиями этой модели в одном месте.",
@@ -1516,6 +1586,8 @@
"newerTooltip": "Эта версия новее вашей последней локальной версии", "newerTooltip": "Эта версия новее вашей последней локальной версии",
"earlyAccess": "Ранний доступ", "earlyAccess": "Ранний доступ",
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai", "earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
"paid": "Платная",
"paidTooltip": "Скачивание этой версии платное",
"ignored": "Игнорируется", "ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены", "ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
"onSiteOnly": "Только на Сайте", "onSiteOnly": "Только на Сайте",
@@ -1524,7 +1596,9 @@
"actions": { "actions": {
"download": "Скачать", "download": "Скачать",
"downloadTooltip": "Скачать эту версию", "downloadTooltip": "Скачать эту версию",
"downloadRemainingTooltip": "Скачать оставшиеся файлы этой версии",
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai", "downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
"downloadPaidTooltip": "Скачать эту платную версию с Civitai",
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai", "downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
"delete": "Удалить", "delete": "Удалить",
"deleteTooltip": "Удалить эту локальную версию", "deleteTooltip": "Удалить эту локальную версию",
@@ -1581,6 +1655,21 @@
"downloadCsv": "Скачать CSV", "downloadCsv": "Скачать CSV",
"columnModelName": "Имя модели", "columnModelName": "Имя модели",
"columnError": "Ошибка" "columnError": "Ошибка"
},
"downloadBatchSummary": {
"title": "Сводка пакетной загрузки",
"statSuccess": "Успешно",
"statFailed": "Ошибки",
"statTotal": "Всего",
"successMessage": "Все {count} моделей успешно загружены",
"completedWithErrors": "Завершено с ошибками",
"failed": "Не удалось загрузить",
"failedItems": "Неудачные элементы ({count})",
"columnName": "Имя модели",
"columnError": "Ошибка",
"close": "Закрыть",
"copyReport": "Скопировать отчёт",
"retryFailed": "Повторить неудачные ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -1679,6 +1768,7 @@
"recipeReplaced": "Рецепт заменён в workflow", "recipeReplaced": "Рецепт заменён в workflow",
"recipeFailedToSend": "Не удалось отправить рецепт в workflow", "recipeFailedToSend": "Не удалось отправить рецепт в workflow",
"noMatchingNodes": "В текущем workflow нет совместимых узлов", "noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noPromptTargets": "В рабочем процессе нет совместимых целей для промпта.\nЩёлкните правой кнопкой мыши по узлу в ComfyUI → Отметить как → Send Prompt Target",
"noTargetNodeSelected": "Целевой узел не выбран", "noTargetNodeSelected": "Целевой узел не выбран",
"modelUpdated": "Модель обновлена в workflow", "modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели", "modelFailed": "Не удалось обновить узел модели",
@@ -1855,6 +1945,7 @@
"downloadPartialSuccess": "Загружено {completed} из {total} LoRAs", "downloadPartialSuccess": "Загружено {completed} из {total} LoRAs",
"downloadPartialWithAccess": "Загружено {completed} из {total} LoRAs. {accessFailures} не удалось из-за ограничений доступа. Проверьте ваш API ключ в настройках или статус раннего доступа.", "downloadPartialWithAccess": "Загружено {completed} из {total} LoRAs. {accessFailures} не удалось из-за ограничений доступа. Проверьте ваш API ключ в настройках или статус раннего доступа.",
"pleaseSelectVersion": "Пожалуйста, выберите версию", "pleaseSelectVersion": "Пожалуйста, выберите версию",
"pleaseSelectFile": "Пожалуйста, выберите хотя бы один файл",
"versionExists": "Эта версия уже существует в вашей библиотеке", "versionExists": "Эта версия уже существует в вашей библиотеке",
"downloadCompleted": "Загрузка успешно завершена", "downloadCompleted": "Загрузка успешно завершена",
"downloadSkippedByBaseModel": "Загрузка пропущена, потому что базовая модель {baseModel} исключена", "downloadSkippedByBaseModel": "Загрузка пропущена, потому что базовая модель {baseModel} исключена",
@@ -1929,6 +2020,12 @@
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})", "repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления", "repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}", "repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
"reimporting": "Переимпорт рецепта из источника...", "reimporting": "Переимпорт рецепта из источника...",
"reimportSuccess": "Рецепт успешно переимпортирован", "reimportSuccess": "Рецепт успешно переимпортирован",
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})", "reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
@@ -2037,7 +2134,6 @@
"presetNameTooLong": "Имя пресета должно содержать не более {max} символов", "presetNameTooLong": "Имя пресета должно содержать не более {max} символов",
"presetNameInvalidChars": "Имя пресета содержит недопустимые символы", "presetNameInvalidChars": "Имя пресета содержит недопустимые символы",
"presetNameExists": "Пресет с таким именем уже существует", "presetNameExists": "Пресет с таким именем уже существует",
"maxPresetsReached": "Допустимо максимум {max} пресетов. Удалите один, чтобы добавить больше.",
"presetNotFound": "Пресет не найден", "presetNotFound": "Пресет не найден",
"invalidPreset": "Недопустимые данные пресета", "invalidPreset": "Недопустимые данные пресета",
"deletePresetFailed": "Не удалось удалить пресет", "deletePresetFailed": "Не удалось удалить пресет",
@@ -2066,6 +2162,14 @@
"updateFailed": "Не удалось обновить триггерные слова", "updateFailed": "Не удалось обновить триггерные слова",
"copyFailed": "Копирование не удалось" "copyFailed": "Копирование не удалось"
}, },
"undo": {
"action": "Отменить",
"deleted": "Удалено: {name}",
"deletedBulk": "Удалено: {count} шт.",
"expired": "Время отмены истекло. Элемент был удалён навсегда.",
"failed": "Не удалось отменить: {error}",
"restored": "Элемент восстановлен"
},
"virtual": { "virtual": {
"loadFailed": "Не удалось загрузить элементы", "loadFailed": "Не удалось загрузить элементы",
"loadMoreFailed": "Не удалось загрузить больше элементов", "loadMoreFailed": "Не удалось загрузить больше элементов",
@@ -2129,6 +2233,7 @@
"fileRenameFailed": "Не удалось переименовать файл: {error}", "fileRenameFailed": "Не удалось переименовать файл: {error}",
"previewUpdated": "Превью успешно обновлено", "previewUpdated": "Превью успешно обновлено",
"previewUploadFailed": "Не удалось загрузить превью изображение", "previewUploadFailed": "Не удалось загрузить превью изображение",
"previewDropInvalid": "Неподдерживаемый тип файла: {name}. Перетащите вместо этого изображение или видео MP4.",
"refreshComplete": "{action} завершено", "refreshComplete": "{action} завершено",
"refreshFailed": "Не удалось {action} {type}s", "refreshFailed": "Не удалось {action} {type}s",
"metadataRefreshed": "Метаданные успешно обновлены", "metadataRefreshed": "Метаданные успешно обновлены",
+111 -6
View File
@@ -186,6 +186,16 @@
"cancelled": "修复已取消。已修复 {count} 个配方。", "cancelled": "修复已取消。已修复 {count} 个配方。",
"error": "配方修复失败:{message}" "error": "配方修复失败:{message}"
}, },
"rematchRecipes": {
"label": "将食谱重新匹配到本地模型",
"loading": "正在将食谱重新匹配到本地模型...",
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱",
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败",
"allFailed": "{failures}/{total} 个食谱重新匹配失败",
"noMatch": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 个食谱已更新({entries} 个条目)。",
"error": "食谱重新匹配失败:{message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "管理已排除的模型" "label": "管理已排除的模型"
}, },
@@ -449,6 +459,12 @@
"compact": "71080p),82K),104K" "compact": "71080p),82K),104K"
}, },
"displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。", "displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。",
"recipesLayout": "配方布局",
"recipesLayoutHelp": "选择配方卡片的排列方式:统一网格,或保留每张图片原始宽高比的瀑布流(Pinterest 风格)布局。",
"recipesLayoutOptions": {
"grid": "网格",
"masonry": "瀑布流"
},
"showFolderSidebar": "显示文件夹侧边栏", "showFolderSidebar": "显示文件夹侧边栏",
"showFolderSidebarHelp": "在模型页面启用或禁用文件夹导航侧边栏。关闭后,侧边栏和悬停区域将保持隐藏。", "showFolderSidebarHelp": "在模型页面启用或禁用文件夹导航侧边栏。关闭后,侧边栏和悬停区域将保持隐藏。",
"cardInfoDisplay": "卡片信息显示", "cardInfoDisplay": "卡片信息显示",
@@ -606,6 +622,10 @@
"label": "隐藏抢先体验更新", "label": "隐藏抢先体验更新",
"help": "抢先体验更新" "help": "抢先体验更新"
}, },
"hidePaidUpdates": {
"label": "隐藏付费更新",
"help": "启用后,仅有付费更新的模型将不显示“有可用更新”徽标"
},
"licenseIcons": { "licenseIcons": {
"useNewStyle": "使用新版许可协议图标", "useNewStyle": "使用新版许可协议图标",
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。" "useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
@@ -678,6 +698,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "自定义(OpenAI 兼容)" "custom": "自定义(OpenAI 兼容)"
}, },
@@ -761,6 +782,7 @@
"copyAll": "复制所选中语法", "copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据", "refreshAll": "刷新所选中元数据",
"repairMetadata": "修复所选中元数据", "repairMetadata": "修复所选中元数据",
"rematchMetadata": "将所选中重新匹配到本地模型",
"reimportMetadata": "从源重新导入", "reimportMetadata": "从源重新导入",
"checkUpdates": "检查所选更新", "checkUpdates": "检查所选更新",
"moveAll": "移动所选中到文件夹", "moveAll": "移动所选中到文件夹",
@@ -816,6 +838,7 @@
"setContentRating": "设置内容评级", "setContentRating": "设置内容评级",
"moveToFolder": "移动到文件夹", "moveToFolder": "移动到文件夹",
"repairMetadata": "修复元数据", "repairMetadata": "修复元数据",
"rematchMetadata": "重新匹配到本地模型",
"reimportMetadata": "从源重新导入", "reimportMetadata": "从源重新导入",
"excludeModel": "排除模型", "excludeModel": "排除模型",
"restoreModel": "恢复模型", "restoreModel": "恢复模型",
@@ -830,7 +853,8 @@
"recipes": { "recipes": {
"title": "LoRA 配方", "title": "LoRA 配方",
"actions": { "actions": {
"sendCheckpoint": "发送到 ComfyUI" "sendCheckpoint": "发送到 ComfyUI",
"sendRecipe": "发送到 ComfyUI"
}, },
"controls": { "controls": {
"import": { "import": {
@@ -901,7 +925,9 @@
"dateAsc": "最早", "dateAsc": "最早",
"lorasCount": "LoRA 数量", "lorasCount": "LoRA 数量",
"lorasCountDesc": "最多", "lorasCountDesc": "最多",
"lorasCountAsc": "最少" "lorasCountAsc": "最少",
"opened": "最近打开",
"openedDesc": "最近打开"
}, },
"refresh": { "refresh": {
"title": "刷新配方列表", "title": "刷新配方列表",
@@ -912,12 +938,25 @@
"favorites": { "favorites": {
"title": "仅显示收藏", "title": "仅显示收藏",
"action": "收藏" "action": "收藏"
},
"layout": {
"title": "配方布局",
"grid": "网格布局",
"masonry": "瀑布流布局(Pinterest 风格,保留图片原始宽高比)"
} }
}, },
"duplicates": { "duplicates": {
"found": "发现 {count} 个重复组", "found": "发现 {count} 个重复组",
"noGroups": "按当前判重依据未找到重复组",
"keepLatest": "保留最新版本", "keepLatest": "保留最新版本",
"deleteSelected": "删除已选" "deleteSelected": "删除已选",
"includePromptLabel": "将提示词纳入判重",
"basis": {
"loraCombo": "判重依据:LoRA 组合",
"loraComboAndPrompt": "判重依据:LoRA 组合 + 提示词",
"hintLoraCombo": "使用相同 LoRA(强度一致)的配方会被分组。",
"hintPromptIncluded": "仅当配方使用相同的 LoRA(强度一致)且提示词相同时才会被分组。"
}
}, },
"contextMenu": { "contextMenu": {
"copyRecipe": { "copyRecipe": {
@@ -1205,11 +1244,13 @@
"downloaded": "已下载", "downloaded": "已下载",
"downloadedTooltip": "之前已下载,但当前不在你的库中。", "downloadedTooltip": "之前已下载,但当前不在你的库中。",
"alreadyInLibrary": "已存在于库中", "alreadyInLibrary": "已存在于库中",
"partiallyDownloaded": "部分已下载",
"autoOrganizedPath": "【已按路径模板自动整理】", "autoOrganizedPath": "【已按路径模板自动整理】",
"fileSelection": { "fileSelection": {
"title": "选择文件格式", "title": "选择文件格式",
"files": "个文件", "files": "个文件",
"select": "选择文件" "select": "选择文件",
"inLibrary": "已在库中"
}, },
"errors": { "errors": {
"invalidUrl": "无效的 Civitai URL 格式", "invalidUrl": "无效的 Civitai URL 格式",
@@ -1250,8 +1291,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "释放 {size}",
"title": "删除模型", "title": "删除模型",
"message": "你确定要删除此模型及所有相关文件吗?" "message": "你确定要删除此模型及所有相关文件吗?",
"recoverableWarning": "如果不撤销,文件将在 20 秒后被永久删除。"
},
"deleteRecipe": {
"recoverableWarning": "此操作可在 20 秒内撤销。"
}, },
"excludeModel": { "excludeModel": {
"title": "排除模型", "title": "排除模型",
@@ -1489,6 +1535,30 @@
"examples": "正在加载示例...", "examples": "正在加载示例...",
"versions": "正在加载版本..." "versions": "正在加载版本..."
}, },
"showcase": {
"hiddenBySfw": "{count} 张因仅显示 SFW 设置而被隐藏",
"showExamples": "显示示例",
"showCount": "显示示例({count}",
"hideExamples": "隐藏示例",
"addExamples": "添加示例",
"previousExample": "上一个示例",
"nextExample": "下一个示例",
"noExamples": "暂无示例图片",
"addMoreExamples": "添加更多示例",
"dragDrop": "将图片或视频拖放到此处",
"or": "或",
"selectFiles": "选择文件",
"supportedFormats": "支持的格式:jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "正在导入文件...",
"noSupportedFiles": "未选择受支持的文件。请选择图片或视频文件。",
"allFiltered": "所有示例图片均因 NSFW 内容设置而被过滤",
"sfwOnlyEnabled": "你当前的设置为仅显示 SFW 内容",
"changeInSettings": "你可以在设置中更改此选项",
"nsfwMature": "成熟内容",
"nsfwR": "R 级内容",
"nsfwX": "X 级内容",
"nsfwXxx": "XXX 级内容"
},
"versions": { "versions": {
"heading": "模型版本", "heading": "模型版本",
"copy": "在一个位置管理该模型的所有版本。", "copy": "在一个位置管理该模型的所有版本。",
@@ -1516,6 +1586,8 @@
"newerTooltip": "此版本比你本地的最新版本更新", "newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "抢先体验", "earlyAccess": "抢先体验",
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限", "earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
"paid": "付费",
"paidTooltip": "此版本需要付费后才能下载",
"ignored": "已忽略", "ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知", "ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成", "onSiteOnly": "仅站内生成",
@@ -1524,7 +1596,9 @@
"actions": { "actions": {
"download": "下载", "download": "下载",
"downloadTooltip": "下载此版本", "downloadTooltip": "下载此版本",
"downloadRemainingTooltip": "下载此版本的剩余文件",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本", "downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"downloadPaidTooltip": "从 Civitai 下载此付费版本",
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载", "downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
"delete": "删除", "delete": "删除",
"deleteTooltip": "删除此本地版本", "deleteTooltip": "删除此本地版本",
@@ -1581,6 +1655,21 @@
"downloadCsv": "下载 CSV", "downloadCsv": "下载 CSV",
"columnModelName": "模型名称", "columnModelName": "模型名称",
"columnError": "错误" "columnError": "错误"
},
"downloadBatchSummary": {
"title": "批量下载摘要",
"statSuccess": "成功",
"statFailed": "失败",
"statTotal": "总数",
"successMessage": "全部 {count} 个模型下载成功",
"completedWithErrors": "已完成,但有错误",
"failed": "下载失败",
"failedItems": "失败项({count}",
"columnName": "模型名称",
"columnError": "错误",
"close": "关闭",
"copyReport": "复制报告",
"retryFailed": "重试失败项({count}"
} }
}, },
"modelTags": { "modelTags": {
@@ -1679,6 +1768,7 @@
"recipeReplaced": "配方已替换到工作流", "recipeReplaced": "配方已替换到工作流",
"recipeFailedToSend": "发送配方到工作流失败", "recipeFailedToSend": "发送配方到工作流失败",
"noMatchingNodes": "当前工作流中没有兼容的节点", "noMatchingNodes": "当前工作流中没有兼容的节点",
"noPromptTargets": "工作流中没有兼容的 prompt 目标节点。\n在 ComfyUI 中右键节点 → Mark as → Send Prompt Target",
"noTargetNodeSelected": "未选择目标节点", "noTargetNodeSelected": "未选择目标节点",
"modelUpdated": "模型已更新到工作流", "modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型节点失败", "modelFailed": "更新模型节点失败",
@@ -1855,6 +1945,7 @@
"downloadPartialSuccess": "已下载 {completed}/{total} 个 LoRA", "downloadPartialSuccess": "已下载 {completed}/{total} 个 LoRA",
"downloadPartialWithAccess": "已下载 {completed}/{total} 个 LoRA。{accessFailures} 个因访问限制失败。请检查设置中的 API 密钥或早期访问状态。", "downloadPartialWithAccess": "已下载 {completed}/{total} 个 LoRA。{accessFailures} 个因访问限制失败。请检查设置中的 API 密钥或早期访问状态。",
"pleaseSelectVersion": "请选择版本", "pleaseSelectVersion": "请选择版本",
"pleaseSelectFile": "请至少选择一个文件",
"versionExists": "该版本已存在于你的库中", "versionExists": "该版本已存在于你的库中",
"downloadCompleted": "下载成功完成", "downloadCompleted": "下载成功完成",
"downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载", "downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载",
@@ -1929,6 +2020,12 @@
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)", "repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
"repairBulkSkipped": "所选 {total} 个配方无需修复", "repairBulkSkipped": "所选 {total} 个配方无需修复",
"repairBulkFailed": "修复所选配方失败:{message}", "repairBulkFailed": "修复所选配方失败:{message}",
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱",
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败",
"rematchAllFailed": "{failures}/{total} 个所选食谱重新匹配失败",
"rematchUnmatched": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配",
"rematchSkipped": "{total} 个所选食谱均无需重新匹配",
"rematchFailed": "重新匹配所选食谱失败:{message}",
"reimporting": "正在从源重新导入配方...", "reimporting": "正在从源重新导入配方...",
"reimportSuccess": "配方已从源重新导入成功", "reimportSuccess": "配方已从源重新导入成功",
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)", "reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
@@ -2037,7 +2134,6 @@
"presetNameTooLong": "预设名称不能超过 {max} 个字符", "presetNameTooLong": "预设名称不能超过 {max} 个字符",
"presetNameInvalidChars": "预设名称包含无效字符", "presetNameInvalidChars": "预设名称包含无效字符",
"presetNameExists": "已存在同名预设", "presetNameExists": "已存在同名预设",
"maxPresetsReached": "最多允许 {max} 个预设。删除一个以添加更多。",
"presetNotFound": "预设未找到", "presetNotFound": "预设未找到",
"invalidPreset": "无效的预设数据", "invalidPreset": "无效的预设数据",
"deletePresetFailed": "删除预设失败", "deletePresetFailed": "删除预设失败",
@@ -2066,6 +2162,14 @@
"updateFailed": "触发词更新失败", "updateFailed": "触发词更新失败",
"copyFailed": "复制失败" "copyFailed": "复制失败"
}, },
"undo": {
"action": "撤销",
"deleted": "已删除 {name}",
"deletedBulk": "已删除 {count} 个项目",
"expired": "撤销窗口已过期,项目已被永久删除。",
"failed": "撤销失败:{error}",
"restored": "项目已恢复"
},
"virtual": { "virtual": {
"loadFailed": "加载项目失败", "loadFailed": "加载项目失败",
"loadMoreFailed": "加载更多项目失败", "loadMoreFailed": "加载更多项目失败",
@@ -2129,6 +2233,7 @@
"fileRenameFailed": "重命名文件失败:{error}", "fileRenameFailed": "重命名文件失败:{error}",
"previewUpdated": "预览图片更新成功", "previewUpdated": "预览图片更新成功",
"previewUploadFailed": "上传预览图片失败", "previewUploadFailed": "上传预览图片失败",
"previewDropInvalid": "不支持的文件类型:{name}。请拖入图片或 MP4 视频。",
"refreshComplete": "{action} 完成", "refreshComplete": "{action} 完成",
"refreshFailed": "{action} {type} 失败", "refreshFailed": "{action} {type} 失败",
"metadataRefreshed": "元数据刷新成功", "metadataRefreshed": "元数据刷新成功",
+112 -7
View File
@@ -186,6 +186,16 @@
"cancelled": "修復已取消。已修復 {count} 個配方。", "cancelled": "修復已取消。已修復 {count} 個配方。",
"error": "配方修復失敗:{message}" "error": "配方修復失敗:{message}"
}, },
"rematchRecipes": {
"label": "將食譜重新匹配到本地模型",
"loading": "正在將食譜重新匹配到本地模型...",
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜",
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗",
"allFailed": "{failures}/{total} 個食譜重新匹配失敗",
"noMatch": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 個食譜已更新({entries} 個條目)。",
"error": "食譜重新匹配失敗:{message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "管理已排除的模型" "label": "管理已排除的模型"
}, },
@@ -449,6 +459,12 @@
"compact": "71080p)、82K)、104K" "compact": "71080p)、82K)、104K"
}, },
"displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。", "displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。",
"recipesLayout": "配方版面",
"recipesLayoutHelp": "選擇配方卡片的排列方式:統一網格,或保留每張圖片原始寬高比的瀑布流(Pinterest 風格)版面。",
"recipesLayoutOptions": {
"grid": "網格",
"masonry": "瀑布流"
},
"showFolderSidebar": "顯示資料夾側邊欄", "showFolderSidebar": "顯示資料夾側邊欄",
"showFolderSidebarHelp": "在模型頁面啟用或停用資料夾導覽側邊欄。停用後,側邊欄與滑鼠懸停區域將保持隱藏。", "showFolderSidebarHelp": "在模型頁面啟用或停用資料夾導覽側邊欄。停用後,側邊欄與滑鼠懸停區域將保持隱藏。",
"cardInfoDisplay": "卡片資訊顯示", "cardInfoDisplay": "卡片資訊顯示",
@@ -606,6 +622,10 @@
"label": "隱藏搶先體驗更新", "label": "隱藏搶先體驗更新",
"help": "搶先體驗更新" "help": "搶先體驗更新"
}, },
"hidePaidUpdates": {
"label": "隱藏付費更新",
"help": "啟用後,只有付費更新的模型將不會顯示「有可用更新」徽章"
},
"licenseIcons": { "licenseIcons": {
"useNewStyle": "使用新版許可協議圖標", "useNewStyle": "使用新版許可協議圖標",
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。" "useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
@@ -678,6 +698,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "自訂(OpenAI 相容)" "custom": "自訂(OpenAI 相容)"
}, },
@@ -686,7 +707,7 @@
"apiBasePlaceholder": "https://api.openai.com/v1", "apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "API 金鑰", "apiKey": "API 金鑰",
"apiKeyHelp": "LLM 提供者的 API 金鑰。儲存在本地,除您選擇的 LLM 提供者外不會傳送到任何伺服器。", "apiKeyHelp": "LLM 提供者的 API 金鑰。儲存在本地,除您選擇的 LLM 提供者外不會傳送到任何伺服器。",
"apiKeyPlaceholder": "[TODO: Translate] sk-...", "apiKeyPlaceholder": "sk-...",
"apiKeyNotSet": "未設定", "apiKeyNotSet": "未設定",
"apiKeyConfigured": "已設定", "apiKeyConfigured": "已設定",
"apiKeySet": "設定", "apiKeySet": "設定",
@@ -761,6 +782,7 @@
"copyAll": "複製全部語法", "copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata", "refreshAll": "刷新全部 metadata",
"repairMetadata": "修復所選中元數據", "repairMetadata": "修復所選中元數據",
"rematchMetadata": "將所選中重新匹配到本地模型",
"reimportMetadata": "從來源重新匯入", "reimportMetadata": "從來源重新匯入",
"checkUpdates": "檢查所選更新", "checkUpdates": "檢查所選更新",
"moveAll": "全部移動到資料夾", "moveAll": "全部移動到資料夾",
@@ -816,6 +838,7 @@
"setContentRating": "設定內容分級", "setContentRating": "設定內容分級",
"moveToFolder": "移動到資料夾", "moveToFolder": "移動到資料夾",
"repairMetadata": "修復元數據", "repairMetadata": "修復元數據",
"rematchMetadata": "重新匹配到本地模型",
"reimportMetadata": "從來源重新匯入", "reimportMetadata": "從來源重新匯入",
"excludeModel": "排除模型", "excludeModel": "排除模型",
"restoreModel": "還原模型", "restoreModel": "還原模型",
@@ -830,7 +853,8 @@
"recipes": { "recipes": {
"title": "LoRA 配方", "title": "LoRA 配方",
"actions": { "actions": {
"sendCheckpoint": "傳送到 ComfyUI" "sendCheckpoint": "傳送到 ComfyUI",
"sendRecipe": "傳送到 ComfyUI"
}, },
"controls": { "controls": {
"import": { "import": {
@@ -901,7 +925,9 @@
"dateAsc": "最舊", "dateAsc": "最舊",
"lorasCount": "LoRA 數量", "lorasCount": "LoRA 數量",
"lorasCountDesc": "最多", "lorasCountDesc": "最多",
"lorasCountAsc": "最少" "lorasCountAsc": "最少",
"opened": "最近開啟",
"openedDesc": "最近開啟"
}, },
"refresh": { "refresh": {
"title": "重新整理配方列表", "title": "重新整理配方列表",
@@ -912,12 +938,25 @@
"favorites": { "favorites": {
"title": "僅顯示收藏", "title": "僅顯示收藏",
"action": "收藏" "action": "收藏"
},
"layout": {
"title": "配方版面",
"grid": "網格版面",
"masonry": "瀑布流版面(Pinterest 風格,保留圖片原始寬高比)"
} }
}, },
"duplicates": { "duplicates": {
"found": "發現 {count} 組重複項", "found": "發現 {count} 組重複項",
"noGroups": "按目前判重依據未找到重複組",
"keepLatest": "保留最新版本", "keepLatest": "保留最新版本",
"deleteSelected": "刪除所選" "deleteSelected": "刪除所選",
"includePromptLabel": "將提示詞納入判重",
"basis": {
"loraCombo": "判重依據:LoRA 組合",
"loraComboAndPrompt": "判重依據:LoRA 組合 + 提示詞",
"hintLoraCombo": "使用相同 LoRA(強度一致)的配方會被分組。",
"hintPromptIncluded": "僅當配方使用相同的 LoRA(強度一致)且提示詞相同時才會被分組。"
}
}, },
"contextMenu": { "contextMenu": {
"copyRecipe": { "copyRecipe": {
@@ -1205,11 +1244,13 @@
"downloaded": "已下載", "downloaded": "已下載",
"downloadedTooltip": "先前已下載,但目前不在你的庫中。", "downloadedTooltip": "先前已下載,但目前不在你的庫中。",
"alreadyInLibrary": "已在庫存", "alreadyInLibrary": "已在庫存",
"partiallyDownloaded": "部分已下載",
"autoOrganizedPath": "[依路徑範本自動整理]", "autoOrganizedPath": "[依路徑範本自動整理]",
"fileSelection": { "fileSelection": {
"title": "選擇檔案格式", "title": "選擇檔案格式",
"files": "個檔案", "files": "個檔案",
"select": "選擇檔案" "select": "選擇檔案",
"inLibrary": "已在庫中"
}, },
"errors": { "errors": {
"invalidUrl": "Civitai 網址格式無效", "invalidUrl": "Civitai 網址格式無效",
@@ -1250,8 +1291,13 @@
} }
}, },
"deleteModel": { "deleteModel": {
"freesSpace": "釋放 {size}",
"title": "刪除模型", "title": "刪除模型",
"message": "您確定要刪除此模型及所有相關檔案嗎?" "message": "您確定要刪除此模型及所有相關檔案嗎?",
"recoverableWarning": "如果未復原,檔案將在 20 秒後被永久刪除。"
},
"deleteRecipe": {
"recoverableWarning": "此操作可在 20 秒內復原。"
}, },
"excludeModel": { "excludeModel": {
"title": "排除模型", "title": "排除模型",
@@ -1489,6 +1535,30 @@
"examples": "載入範例中...", "examples": "載入範例中...",
"versions": "載入版本中..." "versions": "載入版本中..."
}, },
"showcase": {
"hiddenBySfw": "因僅顯示 SFW 設定而隱藏 {count} 張",
"showExamples": "顯示範例",
"showCount": "顯示範例({count}",
"hideExamples": "隱藏範例",
"addExamples": "新增範例",
"previousExample": "上一個範例",
"nextExample": "下一個範例",
"noExamples": "沒有可用的範例圖片",
"addMoreExamples": "新增更多範例",
"dragDrop": "拖放圖片或影片到此處",
"or": "或",
"selectFiles": "選擇檔案",
"supportedFormats": "支援的格式:jpg、png、gif、webp、avif、jxl、mp4、webm",
"importing": "正在匯入檔案...",
"noSupportedFiles": "未選擇支援的檔案。請選擇圖片或影片檔案。",
"allFiltered": "所有範例圖片都因 NSFW 內容設定而被過濾",
"sfwOnlyEnabled": "你目前的設定為僅顯示安全(SFW)內容",
"changeInSettings": "你可以在設定中變更此選項",
"nsfwMature": "成熟內容",
"nsfwR": "R 級內容",
"nsfwX": "X 級內容",
"nsfwXxx": "XXX 級內容"
},
"versions": { "versions": {
"heading": "模型版本", "heading": "模型版本",
"copy": "在同一位置追蹤並管理此模型的所有版本。", "copy": "在同一位置追蹤並管理此模型的所有版本。",
@@ -1516,6 +1586,8 @@
"newerTooltip": "此版本比你本地的最新版本更新", "newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "搶先體驗", "earlyAccess": "搶先體驗",
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限", "earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
"paid": "付費",
"paidTooltip": "此版本需要付費才能下載",
"ignored": "已忽略", "ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知", "ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成", "onSiteOnly": "僅站內生成",
@@ -1524,7 +1596,9 @@
"actions": { "actions": {
"download": "下載", "download": "下載",
"downloadTooltip": "下載此版本", "downloadTooltip": "下載此版本",
"downloadRemainingTooltip": "下載此版本的剩餘檔案",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本", "downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"downloadPaidTooltip": "從 Civitai 下載此付費版本",
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載", "downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
"delete": "刪除", "delete": "刪除",
"deleteTooltip": "刪除此本地版本", "deleteTooltip": "刪除此本地版本",
@@ -1581,6 +1655,21 @@
"downloadCsv": "下載 CSV", "downloadCsv": "下載 CSV",
"columnModelName": "模型名稱", "columnModelName": "模型名稱",
"columnError": "錯誤" "columnError": "錯誤"
},
"downloadBatchSummary": {
"title": "批次下載摘要",
"statSuccess": "成功",
"statFailed": "失敗",
"statTotal": "總數",
"successMessage": "全部 {count} 個模型下載成功",
"completedWithErrors": "已完成,但有錯誤",
"failed": "下載失敗",
"failedItems": "失敗項目({count}",
"columnName": "模型名稱",
"columnError": "錯誤",
"close": "關閉",
"copyReport": "複製報告",
"retryFailed": "重試失敗項目({count}"
} }
}, },
"modelTags": { "modelTags": {
@@ -1679,6 +1768,7 @@
"recipeReplaced": "配方已取代於工作流", "recipeReplaced": "配方已取代於工作流",
"recipeFailedToSend": "傳送配方到工作流失敗", "recipeFailedToSend": "傳送配方到工作流失敗",
"noMatchingNodes": "目前工作流程中沒有相容的節點", "noMatchingNodes": "目前工作流程中沒有相容的節點",
"noPromptTargets": "工作流中沒有相容的 prompt 目標節點。\n在 ComfyUI 中右鍵節點 → Mark as → Send Prompt Target",
"noTargetNodeSelected": "未選擇目標節點", "noTargetNodeSelected": "未選擇目標節點",
"modelUpdated": "模型已更新到工作流", "modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型節點失敗", "modelFailed": "更新模型節點失敗",
@@ -1855,6 +1945,7 @@
"downloadPartialSuccess": "已下載 {completed} 個 LoRA,共 {total} 個", "downloadPartialSuccess": "已下載 {completed} 個 LoRA,共 {total} 個",
"downloadPartialWithAccess": "已下載 {completed} 個 LoRA,共 {total} 個。{accessFailures} 個因訪問限制而失敗。請檢查您的 API 密鑰或提前訪問狀態。", "downloadPartialWithAccess": "已下載 {completed} 個 LoRA,共 {total} 個。{accessFailures} 個因訪問限制而失敗。請檢查您的 API 密鑰或提前訪問狀態。",
"pleaseSelectVersion": "請選擇一個版本", "pleaseSelectVersion": "請選擇一個版本",
"pleaseSelectFile": "請至少選擇一個檔案",
"versionExists": "此版本已存在於您的庫中", "versionExists": "此版本已存在於您的庫中",
"downloadCompleted": "下載成功完成", "downloadCompleted": "下載成功完成",
"downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載", "downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載",
@@ -1929,6 +2020,12 @@
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)", "repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
"repairBulkSkipped": "所選 {total} 個配方無需修復", "repairBulkSkipped": "所選 {total} 個配方無需修復",
"repairBulkFailed": "修復所選配方失敗:{message}", "repairBulkFailed": "修復所選配方失敗:{message}",
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜",
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗",
"rematchAllFailed": "{failures}/{total} 個所選食譜重新匹配失敗",
"rematchUnmatched": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配",
"rematchSkipped": "{total} 個所選食譜均無需重新匹配",
"rematchFailed": "重新匹配所選食譜失敗:{message}",
"reimporting": "正在從來源重新匯入配方...", "reimporting": "正在從來源重新匯入配方...",
"reimportSuccess": "配方已從來源重新匯入成功", "reimportSuccess": "配方已從來源重新匯入成功",
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)", "reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
@@ -2037,7 +2134,6 @@
"presetNameTooLong": "預設名稱不能超過 {max} 個字元", "presetNameTooLong": "預設名稱不能超過 {max} 個字元",
"presetNameInvalidChars": "預設名稱包含無效字元", "presetNameInvalidChars": "預設名稱包含無效字元",
"presetNameExists": "已存在同名預設", "presetNameExists": "已存在同名預設",
"maxPresetsReached": "最多允許 {max} 個預設。刪除一個以新增更多。",
"presetNotFound": "預設未找到", "presetNotFound": "預設未找到",
"invalidPreset": "無效的預設資料", "invalidPreset": "無效的預設資料",
"deletePresetFailed": "刪除預設失敗", "deletePresetFailed": "刪除預設失敗",
@@ -2066,6 +2162,14 @@
"updateFailed": "更新觸發詞失敗", "updateFailed": "更新觸發詞失敗",
"copyFailed": "複製失敗" "copyFailed": "複製失敗"
}, },
"undo": {
"action": "復原",
"deleted": "已刪除 {name}",
"deletedBulk": "已刪除 {count} 個項目",
"expired": "復原視窗已過期,項目已被永久刪除。",
"failed": "復原失敗:{error}",
"restored": "項目已還原"
},
"virtual": { "virtual": {
"loadFailed": "載入項目失敗", "loadFailed": "載入項目失敗",
"loadMoreFailed": "載入更多項目失敗", "loadMoreFailed": "載入更多項目失敗",
@@ -2129,6 +2233,7 @@
"fileRenameFailed": "重新命名檔案失敗:{error}", "fileRenameFailed": "重新命名檔案失敗:{error}",
"previewUpdated": "預覽圖片已成功更新", "previewUpdated": "預覽圖片已成功更新",
"previewUploadFailed": "上傳預覽圖片失敗", "previewUploadFailed": "上傳預覽圖片失敗",
"previewDropInvalid": "不支援的檔案類型:{name}。請拖入圖片或 MP4 影片。",
"refreshComplete": "{action} 完成", "refreshComplete": "{action} 完成",
"refreshFailed": "{action} {type} 失敗", "refreshFailed": "{action} {type} 失敗",
"metadataRefreshed": "metadata 已成功刷新", "metadataRefreshed": "metadata 已成功刷新",
+15 -10
View File
@@ -1,9 +1,13 @@
# pyright: reportImportCycles=false
# Lazy (function-local) imports still count as static edges in basedpyright's
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
import os import os
import platform import platform
import posixpath import posixpath
import threading import threading
from pathlib import Path from pathlib import Path
import folder_paths # type: ignore import folder_paths # pyright: ignore[reportMissingImports]
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
import logging import logging
import json import json
@@ -90,7 +94,7 @@ def _resolve_valid_default_root(
def _normalize_folder_paths_for_comparison( def _normalize_folder_paths_for_comparison(
folder_paths: Mapping[str, Iterable[str]], folder_paths: Mapping[str, Any],
) -> Dict[str, Set[str]]: ) -> Dict[str, Set[str]]:
"""Normalize folder paths for comparison across libraries.""" """Normalize folder paths for comparison across libraries."""
@@ -482,7 +486,7 @@ class Config:
import ctypes import ctypes
FILE_ATTRIBUTE_REPARSE_POINT = 0x400 FILE_ATTRIBUTE_REPARSE_POINT = 0x400
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path)) # type: ignore[attr-defined] attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path)) # pyright: ignore[reportAttributeAccessIssue]
return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT) return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT)
except Exception as e: except Exception as e:
logger.error(f"Error checking Windows reparse point: {e}") logger.error(f"Error checking Windows reparse point: {e}")
@@ -491,7 +495,7 @@ class Config:
logger.error(f"Error checking link status for {path}: {e}") logger.error(f"Error checking link status for {path}: {e}")
return False return False
def _entry_is_symlink(self, entry: os.DirEntry) -> bool: def _entry_is_symlink(self, entry: os.DirEntry[str]) -> bool:
"""Check if a directory entry is a symlink, including Windows junctions.""" """Check if a directory entry is a symlink, including Windows junctions."""
if entry.is_symlink(): if entry.is_symlink():
return True return True
@@ -500,7 +504,7 @@ class Config:
import ctypes import ctypes
FILE_ATTRIBUTE_REPARSE_POINT = 0x400 FILE_ATTRIBUTE_REPARSE_POINT = 0x400
attrs = ctypes.windll.kernel32.GetFileAttributesW(entry.path) # type: ignore[attr-defined] attrs = ctypes.windll.kernel32.GetFileAttributesW(entry.path) # pyright: ignore[reportAttributeAccessIssue]
return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT) return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT)
except Exception: except Exception:
pass pass
@@ -1126,8 +1130,8 @@ class Config:
def _apply_library_paths( def _apply_library_paths(
self, self,
folder_paths: Mapping[str, Iterable[str]], folder_paths: Mapping[str, Any],
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None, extra_folder_paths: Optional[Mapping[str, Any]] = None,
recipes_path: str = "", recipes_path: str = "",
) -> None: ) -> None:
self._path_mappings.clear() self._path_mappings.clear()
@@ -1432,12 +1436,13 @@ class Config:
# ('_lm_config_cache') that is NEVER removed from sys.modules (its key does # ('_lm_config_cache') that is NEVER removed from sys.modules (its key does
# NOT start with 'py.'), so it survives re-imports of py.* modules. # NOT start with 'py.'), so it survives re-imports of py.* modules.
_CONFIG_SENTINEL = "_lm_config_cache" _CONFIG_SENTINEL = "_lm_config_cache"
config: Config
if _CONFIG_SENTINEL in _sys.modules: if _CONFIG_SENTINEL in _sys.modules:
# Re-import: reuse the existing singleton from the sentinel. # Re-import: reuse the existing singleton from the sentinel.
config: Config = _sys.modules[_CONFIG_SENTINEL].config # type: ignore[valid-type] config = _sys.modules[_CONFIG_SENTINEL].config
else: else:
config: Config = Config() config = Config()
# Register the sentinel so re-imports of py.config find us. # Register the sentinel so re-imports of py.config find us.
_sentinel_mod = _types.ModuleType(_CONFIG_SENTINEL) _sentinel_mod = _types.ModuleType(_CONFIG_SENTINEL)
_sentinel_mod.config = config setattr(_sentinel_mod, "config", config)
_sys.modules[_CONFIG_SENTINEL] = _sentinel_mod _sys.modules[_CONFIG_SENTINEL] = _sentinel_mod
+18 -1
View File
@@ -14,7 +14,7 @@ standalone_mode = (
if not standalone_mode: if not standalone_mode:
setup_logging() setup_logging()
from server import PromptServer # type: ignore from server import PromptServer # pyright: ignore[reportMissingImports]
from .config import config from .config import config
from .services.model_service_factory import ( from .services.model_service_factory import (
@@ -25,10 +25,12 @@ from .routes.recipe_routes import RecipeRoutes
from .routes.stats_routes import StatsRoutes from .routes.stats_routes import StatsRoutes
from .routes.update_routes import UpdateRoutes from .routes.update_routes import UpdateRoutes
from .routes.misc_routes import MiscRoutes from .routes.misc_routes import MiscRoutes
from .routes.pending_delete_routes import PendingDeleteRoutes
from .routes.preview_routes import PreviewRoutes from .routes.preview_routes import PreviewRoutes
from .routes.example_images_routes import ExampleImagesRoutes from .routes.example_images_routes import ExampleImagesRoutes
from .services.service_registry import ServiceRegistry from .services.service_registry import ServiceRegistry
from .services.settings_manager import get_settings_manager from .services.settings_manager import get_settings_manager
from .services.pending_delete_service import get_pending_delete_service
from .utils.example_images_migration import ExampleImagesMigration from .utils.example_images_migration import ExampleImagesMigration
from .services.websocket_manager import ws_manager from .services.websocket_manager import ws_manager
from .services.example_images_cleanup_service import ExampleImagesCleanupService from .services.example_images_cleanup_service import ExampleImagesCleanupService
@@ -170,6 +172,7 @@ class LoraManager:
RecipeRoutes.setup_routes(app) RecipeRoutes.setup_routes(app)
UpdateRoutes.setup_routes(app) UpdateRoutes.setup_routes(app)
MiscRoutes.setup_routes(app) MiscRoutes.setup_routes(app)
PendingDeleteRoutes.setup_routes(app)
ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager) ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager)
PreviewRoutes.setup_routes(app) PreviewRoutes.setup_routes(app)
@@ -245,6 +248,20 @@ class LoraManager:
cls._run_post_initialization_tasks(init_tasks), name="post_init_tasks" cls._run_post_initialization_tasks(init_tasks), name="post_init_tasks"
) )
# Startup sweep: purge pending-delete batches that expired during a
# previous run. Non-blocking (fire-and-forget); purge_expired only
# removes already-expired batches, so a staged undo that survived a
# restart stays restorable. scan_roots=True runs the reconciliation
# pass first so leftover batches (the in-process registry is empty
# after a restart) are re-discovered on disk. Covers both plugin
# and standalone modes (StandaloneLoraManager reuses this
# classmethod).
pending_delete_service = await get_pending_delete_service()
asyncio.create_task(
pending_delete_service.purge_expired(scan_roots=True),
name="pending_delete_startup_sweep",
)
logger.debug( logger.debug(
"LoRA Manager: All services initialized and background tasks scheduled" "LoRA Manager: All services initialized and background tasks scheduled"
) )
+2 -2
View File
@@ -22,7 +22,7 @@ if not standalone_mode:
logger.info("ComfyUI Metadata Collector initialized") logger.info("ComfyUI Metadata Collector initialized")
def get_metadata(prompt_id=None): # type: ignore[no-redef] def get_metadata(prompt_id=None): # pyright: ignore[reportRedeclaration]
"""Helper function to get metadata from the registry""" """Helper function to get metadata from the registry"""
registry = MetadataRegistry() registry = MetadataRegistry()
return registry.get_metadata(prompt_id) return registry.get_metadata(prompt_id)
@@ -31,6 +31,6 @@ else:
def init(): def init():
logger.info("ComfyUI Metadata Collector disabled in standalone mode") logger.info("ComfyUI Metadata Collector disabled in standalone mode")
def get_metadata(prompt_id=None): # type: ignore[no-redef] def get_metadata(prompt_id=None): # pyright: ignore[reportRedeclaration]
"""Dummy implementation for standalone mode""" """Dummy implementation for standalone mode"""
return {} return {}
+1 -1
View File
@@ -16,7 +16,7 @@ class MetadataHook:
execution = None execution = None
try: try:
# Try direct import first # Try direct import first
import execution # type: ignore import execution # pyright: ignore[reportMissingImports]
except ImportError: except ImportError:
# Try to locate from system modules # Try to locate from system modules
for module_name in sys.modules: for module_name in sys.modules:
@@ -215,6 +215,24 @@ class MetadataProcessor:
primary_sampler = sampler_info primary_sampler = sampler_info
primary_sampler_id = node_id primary_sampler_id = node_id
# Last resort: any registered sampler. Samplers without a denoise or
# add_noise parameter (e.g. multi-stage samplers like KreaTwoStageSampler)
# are not caught by the criteria above. Prefer execution order so the
# first executed sampler wins, matching the downstream_id branch.
if primary_sampler is None:
sampler_ids = [
node_id
for node_id, sampler_info in metadata.get(SAMPLING, {}).items()
if sampler_info.get(IS_SAMPLER, False)
]
if sampler_ids:
if downstream_id and "execution_order" in metadata:
for node_id in metadata["execution_order"]:
if node_id in sampler_ids:
return node_id, metadata[SAMPLING][node_id]
primary_sampler_id = sampler_ids[0]
primary_sampler = metadata[SAMPLING][sampler_ids[0]]
return primary_sampler_id, primary_sampler return primary_sampler_id, primary_sampler
@staticmethod @staticmethod
+11 -1
View File
@@ -1,5 +1,6 @@
import time import time
from nodes import NODE_CLASS_MAPPINGS # type: ignore from typing import Any
from nodes import NODE_CLASS_MAPPINGS # pyright: ignore[reportMissingImports, reportAttributeAccessIssue]
from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor
from .constants import METADATA_CATEGORIES, IMAGES, OVERWRITE from .constants import METADATA_CATEGORIES, IMAGES, OVERWRITE
@@ -9,6 +10,15 @@ class MetadataRegistry:
_instance = None _instance = None
current_prompt_id: Any = None
current_prompt: Any = None
metadata: dict[str, Any] = {}
prompt_metadata: dict[str, Any] = {}
executed_nodes: set[str] = set()
node_cache: dict[str, Any] = {}
max_prompt_history: int = 3
metadata_categories: list[str] = METADATA_CATEGORIES
def __new__(cls): def __new__(cls):
if cls._instance is None: if cls._instance is None:
cls._instance = super().__new__(cls) cls._instance = super().__new__(cls)
+159 -19
View File
@@ -40,7 +40,7 @@ class GenericNodeExtractor(NodeMetadataExtractor):
* ``MODEL`` output: common input fields (ckpt_name, unet_name, etc.) * ``MODEL`` output: common input fields (ckpt_name, unet_name, etc.)
are checked for a model file name and stored as checkpoint metadata. are checked for a model file name and stored as checkpoint metadata.
* ``CONDITIONING`` output: common text input fields are checked for * ``CONDITIONING`` output: common text input fields are checked for
prompt text and stored as prompt metadata. prompt text, and conditioning inputs are tracked through transforms.
""" """
# Input field names that carry a model path in loader-style nodes. # Input field names that carry a model path in loader-style nodes.
@@ -73,7 +73,7 @@ class GenericNodeExtractor(NodeMetadataExtractor):
_store_checkpoint_metadata(metadata, node_id, name) _store_checkpoint_metadata(metadata, node_id, name)
return return
# — CONDITIONING encoder detection (CLIPTextEncode, Flux, custom) # — CONDITIONING encoder / transform detection
if "CONDITIONING" in return_types or any("CONDITIONING" in str(t) for t in return_types): if "CONDITIONING" in return_types or any("CONDITIONING" in str(t) for t in return_types):
text = None text = None
for field in GenericNodeExtractor._TEXT_FIELDS: for field in GenericNodeExtractor._TEXT_FIELDS:
@@ -81,12 +81,14 @@ class GenericNodeExtractor(NodeMetadataExtractor):
if val and isinstance(val, str) and val.strip(): if val and isinstance(val, str) and val.strip():
text = val.strip() text = val.strip()
break break
input_conditionings = _collect_conditioning_inputs(inputs)
if text or input_conditionings:
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
if text: if text:
prompt_data = metadata.setdefault(PROMPTS, {}) prompt_metadata["text"] = text
prompt_data[node_id] = { if input_conditionings:
"text": text, prompt_metadata["orig_conditionings"] = input_conditionings
"node_id": node_id,
}
@staticmethod @staticmethod
def update(node_id, outputs, metadata, return_types=None): def update(node_id, outputs, metadata, return_types=None):
@@ -98,11 +100,26 @@ class GenericNodeExtractor(NodeMetadataExtractor):
return return
if node_id not in metadata.get(PROMPTS, {}): if node_id not in metadata.get(PROMPTS, {}):
return return
if outputs and isinstance(outputs, list) and len(outputs) > 0: output_tuple = _first_output_tuple(outputs)
if isinstance(outputs[0], tuple) and len(outputs[0]) > 0: if not output_tuple or len(output_tuple) < 1:
cond = outputs[0][0] return
if cond is not None:
metadata[PROMPTS][node_id]["conditioning"] = cond conditioning_index = _first_conditioning_index(return_types)
if conditioning_index is None or len(output_tuple) <= conditioning_index:
return
output_conditioning = output_tuple[conditioning_index]
if output_conditioning is None:
return
prompt_metadata = metadata[PROMPTS][node_id]
prompt_metadata["conditioning"] = output_conditioning
_record_conditioning_source(
metadata,
node_id,
output_conditioning,
prompt_metadata.get("orig_conditionings", []),
)
class CheckpointLoaderExtractor(NodeMetadataExtractor): class CheckpointLoaderExtractor(NodeMetadataExtractor):
@staticmethod @staticmethod
@@ -417,6 +434,34 @@ def _first_output_tuple(outputs):
return None return None
def _first_conditioning_index(return_types):
"""Return the index of the first CONDITIONING output slot, or None."""
if not return_types:
return None
for index, return_type in enumerate(return_types):
if "CONDITIONING" in str(return_type):
return index
return None
def _collect_conditioning_inputs(inputs):
"""Collect conditioning object inputs (``conditioning*`` keys).
Primitive values (None, str, int, float, bool) are excluded so scalar
fields like ``conditioning_strength`` are not mistaken for conditioning
objects during provenance tracking.
"""
if not inputs:
return []
return [
value
for input_name, value in inputs.items()
if input_name.startswith("conditioning")
and value is not None
and not isinstance(value, (str, int, float, bool))
]
def _record_conditioning_source( def _record_conditioning_source(
metadata, node_id, output_conditioning, input_conditionings metadata, node_id, output_conditioning, input_conditionings
): ):
@@ -429,6 +474,14 @@ def _record_conditioning_source(
if not sources: if not sources:
return return
# Identity-preserving selectors return one of their inputs unchanged:
# only that input contributed to the output, so record it alone instead
# of treating every input as a combination source.
for conditioning in sources:
if id(conditioning) == id(output_conditioning):
sources = [conditioning]
break
prompt_metadata = _ensure_prompt_metadata(metadata, node_id) prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
prompt_metadata.setdefault("conditioning_sources", []).append( prompt_metadata.setdefault("conditioning_sources", []).append(
{ {
@@ -508,13 +561,7 @@ class ConditioningCombineExtractor(NodeMetadataExtractor):
if not inputs: if not inputs:
return return
input_conditionings = [] input_conditionings = _collect_conditioning_inputs(inputs)
for input_name in inputs:
if (
input_name.startswith("conditioning")
and inputs[input_name] is not None
):
input_conditionings.append(inputs[input_name])
if input_conditionings: if input_conditionings:
prompt_metadata = _ensure_prompt_metadata(metadata, node_id) prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
@@ -814,6 +861,65 @@ class TSCKSamplerAdvancedExtractor(KSamplerAdvancedExtractor, TSCSamplerBaseExtr
# Update method is inherited from TSCSamplerBaseExtractor # Update method is inherited from TSCSamplerBaseExtractor
class KreaTwoStageSamplerExtractor(BaseSamplerExtractor):
"""Extractor for Krea Two/Three Stage Samplers (Auryg/Krea-2-Two-Stage-Sampler).
The node samples in two (or three) stages with per-stage settings
(stage1_steps/stage2_steps, stage1_cfg/stage2_cfg, ...). The canonical
metadata fields consumed by ``extract_generation_params`` (steps, cfg,
sampler_name, scheduler) are derived from the base stage (stage 1; the
three-stage variant reuses stage 1 settings for stage 3), while the full
per-stage breakdown is preserved in the raw parameters.
"""
# All per-stage parameter keys present on both node variants.
_STAGE_PARAM_KEYS = (
"stage1_steps", "stage1_cfg", "stage1_sampler_name", "stage1_scheduler",
"stage2_steps", "stage2_cfg", "stage2_sampler_name", "stage2_scheduler",
)
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
BaseSamplerExtractor.extract_sampling_params(
node_id,
inputs,
metadata,
("seed", "handoff_percent", "stage3_handoff_percent")
+ KreaTwoStageSamplerExtractor._STAGE_PARAM_KEYS,
)
# Derive the canonical fields expected by extract_generation_params.
sampling_params = metadata[SAMPLING][node_id]["parameters"]
if "stage1_steps" in sampling_params or "stage2_steps" in sampling_params:
sampling_params["steps"] = (
(sampling_params.get("stage1_steps") or 0)
+ (sampling_params.get("stage2_steps") or 0)
)
if "stage1_cfg" in sampling_params:
sampling_params["cfg"] = sampling_params["stage1_cfg"]
if "stage1_sampler_name" in sampling_params:
sampling_params["sampler_name"] = sampling_params["stage1_sampler_name"]
if "stage1_scheduler" in sampling_params:
sampling_params["scheduler"] = sampling_params["stage1_scheduler"]
BaseSamplerExtractor.extract_conditioning(node_id, inputs, metadata)
# Prefer the final generation resolution; latent dims are the fallback.
BaseSamplerExtractor.extract_latent_dimensions(node_id, inputs, metadata)
final_width = inputs.get("final_width")
final_height = inputs.get("final_height")
if final_width and final_height:
if SIZE not in metadata:
metadata[SIZE] = {}
metadata[SIZE][node_id] = {
"width": final_width,
"height": final_height,
"node_id": node_id,
}
class LoraLoaderExtractor(NodeMetadataExtractor): class LoraLoaderExtractor(NodeMetadataExtractor):
@staticmethod @staticmethod
def extract(node_id, inputs, outputs, metadata): def extract(node_id, inputs, outputs, metadata):
@@ -854,6 +960,37 @@ class ImageSizeExtractor(NodeMetadataExtractor):
"node_id": node_id "node_id": node_id
} }
class KreaDualResolutionSelectorExtractor(NodeMetadataExtractor):
"""Extract base resolution from Krea Dual Resolution Selector outputs
(Auryg/Krea-2-Two-Stage-Sampler).
The node computes base/final dimensions at runtime from aspect ratio and
megapixel settings, so the values are only available in the update phase
(outputs: base_width, base_height, final_width, final_height, seed).
"""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
# Dimensions are computed at runtime; nothing to do here.
pass
@staticmethod
def update(node_id, outputs, metadata):
output_tuple = _first_output_tuple(outputs)
if not output_tuple or len(output_tuple) < 2:
return
width, height = output_tuple[0], output_tuple[1]
if not isinstance(width, int) or not isinstance(height, int):
return
if SIZE not in metadata:
metadata[SIZE] = {}
metadata[SIZE][node_id] = {
"width": width,
"height": height,
"node_id": node_id,
}
class RgthreePowerLoraLoaderExtractor(NodeMetadataExtractor): class RgthreePowerLoraLoaderExtractor(NodeMetadataExtractor):
"""Extract LoRA metadata from rgthree Power Lora Loader. """Extract LoRA metadata from rgthree Power Lora Loader.
@@ -1255,6 +1392,8 @@ NODE_EXTRACTORS = {
"ClownsharKSampler_Beta": SamplerExtractor, "ClownsharKSampler_Beta": SamplerExtractor,
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes "TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
"TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # Efficient Nodes "TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # Efficient Nodes
"KreaTwoStageSampler": KreaTwoStageSamplerExtractor, # Auryg/Krea-2-Two-Stage-Sampler
"KreaThreeStageSampler": KreaTwoStageSamplerExtractor, # Auryg/Krea-2-Two-Stage-Sampler
"KSamplerBasicPipe": KSamplerBasicPipeExtractor, # comfyui-impact-pack "KSamplerBasicPipe": KSamplerBasicPipeExtractor, # comfyui-impact-pack
"KSamplerAdvancedBasicPipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-impact-pack "KSamplerAdvancedBasicPipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-impact-pack
"KSampler_inspire_pipe": KSamplerBasicPipeExtractor, # comfyui-inspire-pack "KSampler_inspire_pipe": KSamplerBasicPipeExtractor, # comfyui-inspire-pack
@@ -1306,6 +1445,7 @@ NODE_EXTRACTORS = {
"GetNode": GetNodeExtractor, "GetNode": GetNodeExtractor,
# Latent # Latent
"EmptyLatentImage": ImageSizeExtractor, "EmptyLatentImage": ImageSizeExtractor,
"KreaDualResolutionSelector": KreaDualResolutionSelectorExtractor, # Auryg/Krea-2-Two-Stage-Sampler
# Flux # Flux
"FluxGuidance": FluxGuidanceExtractor, # Add FluxGuidance "FluxGuidance": FluxGuidanceExtractor, # Add FluxGuidance
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider "CFGGuider": CFGGuiderExtractor, # Add CFGGuider
+11 -2
View File
@@ -8,7 +8,7 @@ cannot drift between the two paths.
import logging import logging
from typing import Any, Dict from typing import Any, Dict
from ..utils.utils import model_patcher_to_name from ..utils.utils import model_patcher_to_name, sampler_object_to_name
from .constants import CLIP_SKIP_SENTINEL, METADATA_OVERWRITE_FIELDS from .constants import CLIP_SKIP_SENTINEL, METADATA_OVERWRITE_FIELDS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -22,7 +22,9 @@ def collect_overwrite_params(values: Dict[str, Any]) -> Dict[str, Any]:
of 0 is preserved. The ``model`` field accepts either a manual string or of 0 is preserved. The ``model`` field accepts either a manual string or
a wired MODEL (ModelPatcher) connection; in the latter case the source a wired MODEL (ModelPatcher) connection; in the latter case the source
model name is extracted from the patcher's ``cached_patcher_init`` and model name is extracted from the patcher's ``cached_patcher_init`` and
stored as a ComfyUI-style relative path. stored as a ComfyUI-style relative path. The ``sampler`` field likewise
accepts a manual string or a wired SAMPLER (KSAMPLER) connection, from
which the sampler name is extracted via the sampler function's name.
""" """
result: Dict[str, Any] = {} result: Dict[str, Any] = {}
for key in METADATA_OVERWRITE_FIELDS: for key in METADATA_OVERWRITE_FIELDS:
@@ -34,6 +36,13 @@ def collect_overwrite_params(values: Dict[str, Any]) -> Dict[str, Any]:
"Could not extract model name from wired MODEL input " "Could not extract model name from wired MODEL input "
"(no cached_patcher_init); model metadata overwrite skipped" "(no cached_patcher_init); model metadata overwrite skipped"
) )
elif key == "sampler" and not isinstance(value, str):
value = sampler_object_to_name(value)
if value is None:
logger.warning(
"Could not extract sampler name from wired SAMPLER input "
"(unrecognized sampler function); sampler metadata overwrite skipped"
)
if key == "clip_skip": if key == "clip_skip":
if value != CLIP_SKIP_SENTINEL: if value != CLIP_SKIP_SENTINEL:
result[key] = value result[key] = value
+2 -2
View File
@@ -43,7 +43,7 @@ SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys())
async def _find_model_entry( async def _find_model_entry(
model_path: str, model_path: str,
) -> tuple[object, object, str | None] | tuple[None, None, None]: ) -> tuple[Any, object, str | None] | tuple[None, None, None]:
"""Iterate all scanners and return the first (scanner, entry, getter_name) """Iterate all scanners and return the first (scanner, entry, getter_name)
that owns *model_path*. Returns ``(None, None, None)`` when no scanner that owns *model_path*. Returns ``(None, None, None)`` when no scanner
claims it. claims it.
@@ -73,7 +73,7 @@ async def _find_model_entry(
async def _find_scanner_for_model( async def _find_scanner_for_model(
model_path: str, model_path: str,
) -> tuple[object, object] | tuple[None, None]: ) -> tuple[Any, object] | tuple[None, None]:
"""Find the (scanner, cache_entry) responsible for *model_path*.""" """Find the (scanner, cache_entry) responsible for *model_path*."""
scanner, entry, _ = await _find_model_entry(model_path) scanner, entry, _ = await _find_model_entry(model_path)
return scanner, entry return scanner, entry
+87 -8
View File
@@ -1,7 +1,8 @@
import logging import logging
from typing import List, Tuple import os
import comfy.sd # type: ignore from typing import Any, List, Tuple
import folder_paths # type: ignore import comfy.sd # pyright: ignore[reportMissingImports]
import folder_paths # pyright: ignore[reportMissingImports]
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -12,20 +13,42 @@ class CheckpointLoaderLM:
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
extra folder paths, providing a unified interface for checkpoint loading. extra folder paths, providing a unified interface for checkpoint loading.
The ckpt_name combo supports ComfyUI's control_after_generate, letting
users pick a random checkpoint on every run; the base_model input narrows
the random pool through a front-end extension that filters the combo
options.
""" """
NAME = "Checkpoint Loader (LoraManager)" NAME = "Checkpoint Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders" CATEGORY = "Lora Manager/loaders"
@classmethod @classmethod
def INPUT_TYPES(s): def INPUT_TYPES(cls):
# Get list of checkpoint names from scanner (includes extra folder paths) # Get list of checkpoint names from scanner (includes extra folder paths)
checkpoint_names = s._get_checkpoint_names() checkpoint_names = cls._get_checkpoint_names()
base_models = cls._get_available_base_models()
return { return {
"required": { "required": {
"ckpt_name": ( "ckpt_name": (
checkpoint_names, checkpoint_names,
{"tooltip": "The name of the checkpoint (model) to load."}, {
"tooltip": (
"The name of the checkpoint (model) to load. Use "
"control_after_generate to pick a random model on "
"every run."
),
"control_after_generate": "fixed",
},
),
"base_model": (
base_models,
{
"default": "Any",
"tooltip": (
"Restrict the random selection pool to this base "
"model. 'Any' uses the full pool."
),
},
), ),
} }
} }
@@ -58,7 +81,10 @@ class CheckpointLoaderLM:
for item in cache.raw_data: for item in cache.raw_data:
if item.get("sub_type") == "checkpoint": if item.get("sub_type") == "checkpoint":
file_path = item.get("file_path", "") file_path = item.get("file_path", "")
if file_path: # Only offer models that still exist on disk so ComfyUI
# flags missing checkpoints at queue time via
# "value not in list" (the scanner cache can be stale).
if file_path and os.path.exists(file_path):
# Format using relative path with OS-native separator # Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui( formatted_name = _format_model_name_for_comfyui(
file_path, model_roots file_path, model_roots
@@ -89,15 +115,68 @@ class CheckpointLoaderLM:
logger.error(f"Error getting checkpoint names: {e}") logger.error(f"Error getting checkpoint names: {e}")
return [] return []
def load_checkpoint(self, ckpt_name: str) -> Tuple: @classmethod
def _get_available_base_models(cls) -> List[str]:
"""Get distinct base_model values present among indexed checkpoints, for the random-selection filter."""
try:
from ..services.service_registry import ServiceRegistry
async def _get_base_models():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
base_models = set()
for item in cache.raw_data:
if item.get("sub_type") != "checkpoint":
continue
base_model = item.get("base_model")
file_path = item.get("file_path", "")
if base_model and file_path and os.path.exists(file_path):
base_models.add(base_model)
return sorted(base_models)
return ["Any"] + cls._run_async(_get_base_models)
except Exception as e:
logger.error(f"Error getting available base models: {e}")
return ["Any"]
@staticmethod
def _run_async(coro_fn):
"""Run an async fetcher, handling the case where an event loop is already running."""
import asyncio
try:
asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(coro_fn())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(coro_fn())
def load_checkpoint(
self, ckpt_name: str, base_model: str = "Any"
) -> Tuple[Any, Any, Any]:
"""Load a checkpoint by name, supporting extra folder paths """Load a checkpoint by name, supporting extra folder paths
Args: Args:
ckpt_name: The name of the checkpoint to load (relative path with extension) ckpt_name: The name of the checkpoint to load (relative path with extension)
base_model: Only used by the front-end to filter the random pool
Returns: Returns:
Tuple of (MODEL, CLIP, VAE) Tuple of (MODEL, CLIP, VAE)
""" """
del base_model
# Get absolute path from cache using ComfyUI-style name # Get absolute path from cache using ComfyUI-style name
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name) ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
+11 -4
View File
@@ -15,6 +15,7 @@ from .utils import (
any_type, any_type,
apply_lora_syntax_format, apply_lora_syntax_format,
get_loras_list, get_loras_list,
validate_lora_entries,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,15 +39,21 @@ class CreateHookLoraLM:
), ),
}, },
), ),
"loras": ("LORAS", {}),
}, },
"optional": FlexibleOptionalInputType(any_type), "optional": FlexibleOptionalInputType(any_type),
} }
@classmethod
def VALIDATE_INPUTS(cls, loras=None):
"""Queue-time validation: reject missing local LoRAs before execution."""
return validate_lora_entries({"loras": loras}) or True
RETURN_TYPES = ("HOOKS", "STRING", "STRING") RETURN_TYPES = ("HOOKS", "STRING", "STRING")
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras") RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
FUNCTION = "create_hook" FUNCTION = "create_hook"
def create_hook(self, text: str, **kwargs): def create_hook(self, text: str, loras, **kwargs):
"""Create a HookGroup from the selected LoRAs, chained with prev_hooks. """Create a HookGroup from the selected LoRAs, chained with prev_hooks.
Each active LoRA from the widget is loaded and wrapped in a WeightHook Each active LoRA from the widget is loaded and wrapped in a WeightHook
@@ -57,8 +64,8 @@ class CreateHookLoraLM:
del text # used by the frontend widget only del text # used by the frontend widget only
# Lazy imports: comfy is not available in CI/test environment at module level # Lazy imports: comfy is not available in CI/test environment at module level
import comfy.hooks # type: ignore # noqa: C0415 import comfy.hooks # pyright: ignore[reportMissingImports] # noqa: C0415
import comfy.utils # type: ignore # noqa: C0415 import comfy.utils # pyright: ignore[reportMissingImports] # noqa: C0415
prev_hooks: comfy.hooks.HookGroup | None = kwargs.get("prev_hooks") prev_hooks: comfy.hooks.HookGroup | None = kwargs.get("prev_hooks")
@@ -67,7 +74,7 @@ class CreateHookLoraLM:
all_trigger_words: list[str] = [] all_trigger_words: list[str] = []
active_loras: list[tuple[str, float, float]] = [] active_loras: list[tuple[str, float, float]] = []
for lora in get_loras_list(kwargs): for lora in get_loras_list({"loras": loras}):
if not lora.get("active", False): if not lora.get("active", False):
continue continue
+14 -7
View File
@@ -1,8 +1,8 @@
import importlib import importlib
import logging import logging
import comfy.sd # type: ignore import comfy.sd # pyright: ignore[reportMissingImports]
import comfy.utils # type: ignore import comfy.utils # pyright: ignore[reportMissingImports]
from ..utils.utils import get_lora_info_absolute from ..utils.utils import get_lora_info_absolute
from .utils import ( from .utils import (
@@ -14,6 +14,7 @@ from .utils import (
get_loras_list, get_loras_list,
nunchaku_load_lora, nunchaku_load_lora,
parse_lora_syntax, parse_lora_syntax,
validate_lora_entries,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,9 +49,9 @@ def _collect_stack_entries(lora_stack):
return entries return entries
def _collect_widget_entries(kwargs): def _collect_widget_entries(loras):
entries = [] entries = []
for lora in get_loras_list(kwargs): for lora in get_loras_list({"loras": loras}):
if not lora.get("active", False): if not lora.get("active", False):
continue continue
lora_name = apply_lora_syntax_format(lora["name"]) lora_name = apply_lora_syntax_format(lora["name"])
@@ -138,20 +139,26 @@ class LoraLoaderLM:
"placeholder": "Search LoRAs to add...", "placeholder": "Search LoRAs to add...",
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation", "tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
}), }),
"loras": ("LORAS", {}),
}, },
"optional": FlexibleOptionalInputType(any_type), "optional": FlexibleOptionalInputType(any_type),
} }
@classmethod
def VALIDATE_INPUTS(cls, loras=None):
"""Queue-time validation: reject missing local LoRAs before execution."""
return validate_lora_entries({"loras": loras}) or True
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING") RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras") RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
FUNCTION = "load_loras" FUNCTION = "load_loras"
def load_loras(self, model, text, **kwargs): def load_loras(self, model, text, loras, **kwargs):
"""Loads multiple LoRAs based on the kwargs input and lora_stack.""" """Loads multiple LoRAs based on the widget input and lora_stack."""
del text del text
clip = kwargs.get("clip", None) clip = kwargs.get("clip", None)
lora_entries = _collect_stack_entries(kwargs.get("lora_stack", None)) lora_entries = _collect_stack_entries(kwargs.get("lora_stack", None))
lora_entries.extend(_collect_widget_entries(kwargs)) lora_entries.extend(_collect_widget_entries(loras))
nunchaku_model_kind = detect_nunchaku_model_kind(model) nunchaku_model_kind = detect_nunchaku_model_kind(model)
if nunchaku_model_kind == "flux": if nunchaku_model_kind == "flux":
+6
View File
@@ -9,6 +9,7 @@ and tracks the last used combination for reuse.
import logging import logging
import os import os
from ..utils.utils import get_lora_info from ..utils.utils import get_lora_info
from .utils import validate_lora_entries
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -31,6 +32,11 @@ class LoraRandomizerLM:
}, },
} }
@classmethod
def VALIDATE_INPUTS(cls, loras=None):
"""Queue-time validation: reject missing local LoRAs before execution."""
return validate_lora_entries({"loras": loras}) or True
RETURN_TYPES = ("LORA_STACK",) RETURN_TYPES = ("LORA_STACK",)
RETURN_NAMES = ("LORA_STACK",) RETURN_NAMES = ("LORA_STACK",)
+1 -1
View File
@@ -73,7 +73,7 @@ class LoraStackCombinerLM:
stack = inspect.stack() stack = inspect.stack()
if len(stack) > 2 and stack[2].function == "get_input_info": if len(stack) > 2 and stack[2].function == "get_input_info":
optional_inputs = _LoraStackOptionalInputs(optional_inputs) # type: ignore[assignment] optional_inputs = _LoraStackOptionalInputs(optional_inputs) # pyright: ignore[reportAssignmentType]
return { return {
"required": {}, "required": {},
+11 -5
View File
@@ -1,6 +1,6 @@
import os import os
from ..utils.utils import get_lora_info from ..utils.utils import get_lora_info
from .utils import FlexibleOptionalInputType, any_type, apply_lora_syntax_format, extract_lora_name, get_loras_list from .utils import FlexibleOptionalInputType, any_type, apply_lora_syntax_format, extract_lora_name, get_loras_list, validate_lora_entries
import logging import logging
@@ -18,16 +18,22 @@ class LoraStackerLM:
"placeholder": "Search LoRAs to add...", "placeholder": "Search LoRAs to add...",
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation", "tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
}), }),
"loras": ("LORAS", {}),
}, },
"optional": FlexibleOptionalInputType(any_type), "optional": FlexibleOptionalInputType(any_type),
} }
@classmethod
def VALIDATE_INPUTS(cls, loras=None):
"""Queue-time validation: reject missing local LoRAs before execution."""
return validate_lora_entries({"loras": loras}) or True
RETURN_TYPES = ("LORA_STACK", "STRING", "STRING") RETURN_TYPES = ("LORA_STACK", "STRING", "STRING")
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras") RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
FUNCTION = "stack_loras" FUNCTION = "stack_loras"
def stack_loras(self, text, **kwargs): def stack_loras(self, text, loras, **kwargs):
"""Stacks multiple LoRAs based on the kwargs input without loading them.""" """Stacks multiple LoRAs based on the widget input without loading them."""
stack = [] stack = []
active_loras = [] active_loras = []
all_trigger_words = [] all_trigger_words = []
@@ -42,8 +48,8 @@ class LoraStackerLM:
_, trigger_words = get_lora_info(lora_name) _, trigger_words = get_lora_info(lora_name)
all_trigger_words.extend(trigger_words) all_trigger_words.extend(trigger_words)
# Process loras from kwargs with support for both old and new formats # Process loras from the widget with support for both old and new formats
loras_list = get_loras_list(kwargs) loras_list = get_loras_list({"loras": loras})
for lora in loras_list: for lora in loras_list:
if not lora.get('active', False): if not lora.get('active', False):
continue continue
+13 -3
View File
@@ -71,10 +71,18 @@ class MetadataOverwriteLM:
}, },
), ),
"sampler": ( "sampler": (
"STRING", "STRING,SAMPLER",
{ {
"default": "", "default": "",
"tooltip": "Sampler name. Only overwrites when non-empty.", "widgetType": "STRING",
"tooltip": (
"Sampler name. Fill in the name manually or "
"connect a SAMPLER output (e.g. KSamplerSelect) "
"— the sampler name is then extracted "
"automatically. Note: ddim is recorded as "
"euler (ComfyUI internal representation). "
"Only overwrites when non-empty."
),
}, },
), ),
"scheduler": ( "scheduler": (
@@ -164,6 +172,8 @@ class MetadataOverwriteLM:
The ``model`` field accepts either a manual string or a wired MODEL The ``model`` field accepts either a manual string or a wired MODEL
(ModelPatcher) connection; in the latter case the underlying model (ModelPatcher) connection; in the latter case the underlying model
name is extracted from the patcher's ``cached_patcher_init`` and name is extracted from the patcher's ``cached_patcher_init`` and
stored as a ComfyUI-style relative path. stored as a ComfyUI-style relative path. The ``sampler`` field
likewise accepts a manual string or a wired SAMPLER (KSAMPLER)
connection, from which the sampler name is extracted automatically.
""" """
return (collect_overwrite_params(kwargs),) return (collect_overwrite_params(kwargs),)
+12 -13
View File
@@ -15,15 +15,15 @@ import os
import re import re
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union, cast
import comfy.utils # type: ignore import comfy.utils # pyright: ignore[reportMissingImports]
import folder_paths # type: ignore import folder_paths # pyright: ignore[reportMissingImports]
import torch import torch
import torch.nn as nn import torch.nn as nn
from safetensors import safe_open from safetensors import safe_open
from nunchaku.lora.flux.nunchaku_converter import ( from nunchaku.lora.flux.nunchaku_converter import ( # pyright: ignore[reportMissingTypeStubs]
pack_lowrank_weight, pack_lowrank_weight,
unpack_lowrank_weight, unpack_lowrank_weight,
) )
@@ -87,10 +87,6 @@ def _rename_layer_underscore_layer_name(old_name: str) -> str:
return new_name return new_name
def _is_indexable_module(module):
return isinstance(module, (nn.ModuleList, nn.Sequential, list, tuple))
def _get_module_by_name(model: nn.Module, name: str) -> Optional[nn.Module]: def _get_module_by_name(model: nn.Module, name: str) -> Optional[nn.Module]:
if not name: if not name:
return model return model
@@ -100,7 +96,7 @@ def _get_module_by_name(model: nn.Module, name: str) -> Optional[nn.Module]:
continue continue
if hasattr(module, part): if hasattr(module, part):
module = getattr(module, part) module = getattr(module, part)
elif part.isdigit() and _is_indexable_module(module): elif part.isdigit() and isinstance(module, (nn.ModuleList, nn.Sequential, list, tuple)):
try: try:
module = module[int(part)] module = module[int(part)]
except (IndexError, TypeError): except (IndexError, TypeError):
@@ -267,7 +263,9 @@ def _handle_proj_out_split(lora_dict: Dict[str, Dict[str, torch.Tensor]], base_k
return result, consumed return result, consumed
def _apply_lora_to_module(module: nn.Module, a_tensor: torch.Tensor, b_tensor: torch.Tensor, module_name: str, model: nn.Module) -> None: def _apply_lora_to_module(module: Any, a_tensor: torch.Tensor, b_tensor: torch.Tensor, module_name: str, model: Any) -> None:
# These modules are dynamic torch containers; monkey-patched attributes
# below are set at runtime, so the module/model types are deliberately Any.
if not hasattr(module, "in_features") or not hasattr(module, "out_features"): if not hasattr(module, "in_features") or not hasattr(module, "out_features"):
raise ValueError(f"{module_name}: unsupported module without in/out features") raise ValueError(f"{module_name}: unsupported module without in/out features")
if a_tensor.shape[1] != module.in_features or b_tensor.shape[0] != module.out_features: if a_tensor.shape[1] != module.in_features or b_tensor.shape[0] != module.out_features:
@@ -336,7 +334,7 @@ def _apply_lora_to_module(module: nn.Module, a_tensor: torch.Tensor, b_tensor: t
raise ValueError(f"{module_name}: unsupported module type {type(module)}") raise ValueError(f"{module_name}: unsupported module type {type(module)}")
def reset_lora_v2(model: nn.Module) -> None: def reset_lora_v2(model: Any) -> None:
slots = getattr(model, "_lora_slots", None) slots = getattr(model, "_lora_slots", None)
if not slots: if not slots:
return return
@@ -344,6 +342,7 @@ def reset_lora_v2(model: nn.Module) -> None:
module = _get_module_by_name(model, name) module = _get_module_by_name(model, name)
if module is None: if module is None:
continue continue
module = cast(Any, module)
module_type = info.get("type", "nunchaku") module_type = info.get("type", "nunchaku")
if module_type == "nunchaku": if module_type == "nunchaku":
base_rank = info["base_rank"] base_rank = info["base_rank"]
@@ -371,7 +370,7 @@ def reset_lora_v2(model: nn.Module) -> None:
def compose_loras_v2(model: nn.Module, lora_configs: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]], apply_awq_mod: bool = True) -> bool: def compose_loras_v2(model: nn.Module, lora_configs: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]], apply_awq_mod: bool = True) -> bool:
del apply_awq_mod # retained for interface compatibility del apply_awq_mod # retained for interface compatibility
reset_lora_v2(model) reset_lora_v2(model)
aggregated_weights: Dict[str, List[Dict[str, object]]] = defaultdict(list) aggregated_weights: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
saw_supported_format = False saw_supported_format = False
unresolved_targets = 0 unresolved_targets = 0
@@ -471,7 +470,7 @@ def compose_loras_v2(model: nn.Module, lora_configs: List[Tuple[Union[str, Path,
class ComfyQwenImageWrapperLM(nn.Module): class ComfyQwenImageWrapperLM(nn.Module):
def __init__(self, model: nn.Module, config=None, apply_awq_mod: bool = True): def __init__(self, model: nn.Module, config=None, apply_awq_mod: bool = True):
super().__init__() super().__init__()
self.model = model self.model: Any = model
self.config = {} if config is None else config self.config = {} if config is None else config
self.dtype = next(model.parameters()).dtype self.dtype = next(model.parameters()).dtype
self.loras: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]] = [] self.loras: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]] = []
+2 -2
View File
@@ -67,7 +67,7 @@ class PromptLM:
stack = inspect.stack() stack = inspect.stack()
if len(stack) > 2 and stack[2].function == "get_input_info": if len(stack) > 2 and stack[2].function == "get_input_info":
optional_inputs = _PromptOptionalInputs(optional_inputs) # type: ignore[assignment] optional_inputs = _PromptOptionalInputs(optional_inputs) # pyright: ignore[reportAssignmentType]
return { return {
"required": { "required": {
@@ -126,7 +126,7 @@ class PromptLM:
else: else:
prompt = expanded_text prompt = expanded_text
from nodes import CLIPTextEncode # type: ignore from nodes import CLIPTextEncode # pyright: ignore[reportMissingImports, reportAttributeAccessIssue]
conditioning = CLIPTextEncode().encode(clip, prompt)[0] conditioning = CLIPTextEncode().encode(clip, prompt)[0]
return (conditioning, prompt) return (conditioning, prompt)
+214
View File
@@ -0,0 +1,214 @@
import logging
import os
import random
from typing import Any, List, Optional, Tuple
import comfy.sd # pyright: ignore[reportMissingImports]
import folder_paths # pyright: ignore[reportMissingImports]
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
class RandomCheckpointLoaderLM:
"""Checkpoint Loader that can randomly pick a checkpoint from the pool
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
extra folder paths. When select_at_random is enabled, ignores ckpt_name
and picks a random checkpoint (optionally filtered by base_model) on
every run.
"""
NAME = "Random Checkpoint Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(cls):
# Get list of checkpoint names from scanner (includes extra folder paths)
checkpoint_names = cls._get_checkpoint_names()
base_models = cls._get_available_base_models()
return {
"required": {
"ckpt_name": (
checkpoint_names,
{"tooltip": "The name of the checkpoint (model) to load."},
),
"select_at_random": (
"BOOLEAN",
{
"default": False,
"tooltip": (
"Ignore ckpt_name and pick a random checkpoint from the "
"pool (optionally filtered by base_model) on every run."
),
},
),
"base_model": (
base_models,
{
"default": "Any",
"tooltip": "Restrict random selection to this base model. 'Any' uses the full pool.",
},
),
}
}
RETURN_TYPES = ("MODEL", "CLIP", "VAE", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "VAE", "model_name")
OUTPUT_TOOLTIPS = (
"The model used for denoising latents.",
"The CLIP model used for encoding text prompts.",
"The VAE model used for encoding and decoding images to and from latent space.",
"The name of the checkpoint that was loaded (useful when select_at_random is enabled).",
)
FUNCTION = "load_checkpoint"
@classmethod
def IS_CHANGED(cls, ckpt_name, select_at_random=False, base_model="Any"):
# Force re-execution on every run while randomizing, since the widget
# values themselves don't change between queue runs.
if select_at_random:
return float("nan")
return ckpt_name
@staticmethod
def _run_async(coro_fn):
"""Run an async fetcher, handling the case where an event loop is already running."""
import asyncio
try:
asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(coro_fn())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(coro_fn())
@classmethod
def _get_checkpoint_names(cls, base_model: Optional[str] = None) -> List[str]:
"""Get list of checkpoint names from scanner cache in ComfyUI format (relative path with extension)
Args:
base_model: If given (and not "Any"), only include checkpoints matching this base model.
"""
try:
from ..services.service_registry import ServiceRegistry
async def _get_names():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get all model roots for calculating relative paths
model_roots = scanner.get_model_roots()
# Filter only checkpoint type (not diffusion_model) and format names
names = []
for item in cache.raw_data:
if item.get("sub_type") != "checkpoint":
continue
if (
base_model
and base_model != "Any"
and item.get("base_model") != base_model
):
continue
file_path = item.get("file_path", "")
# Only offer models that still exist on disk so ComfyUI
# flags missing checkpoints at queue time via
# "value not in list" (the scanner cache can be stale).
if file_path and os.path.exists(file_path):
# Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui(
file_path, model_roots
)
if formatted_name:
names.append(formatted_name)
return sorted(names)
return cls._run_async(_get_names)
except Exception as e:
logger.error(f"Error getting checkpoint names: {e}")
return []
@classmethod
def _get_available_base_models(cls) -> List[str]:
"""Get distinct base_model values present among indexed checkpoints, for the random-selection filter."""
try:
from ..services.service_registry import ServiceRegistry
async def _get_base_models():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
base_models = set()
for item in cache.raw_data:
if item.get("sub_type") != "checkpoint":
continue
base_model = item.get("base_model")
file_path = item.get("file_path", "")
if base_model and file_path and os.path.exists(file_path):
base_models.add(base_model)
return sorted(base_models)
return ["Any"] + cls._run_async(_get_base_models)
except Exception as e:
logger.error(f"Error getting available base models: {e}")
return ["Any"]
def load_checkpoint(
self,
ckpt_name: str,
select_at_random: bool = False,
base_model: str = "Any",
) -> Tuple[Any, Any, Any, str]:
"""Load a checkpoint by name, supporting extra folder paths
Args:
ckpt_name: The name of the checkpoint to load (relative path with extension)
select_at_random: If True, ignore ckpt_name and pick randomly from the pool
base_model: Restricts random selection to this base model ("Any" = no filter)
Returns:
Tuple of (MODEL, CLIP, VAE, model_name)
"""
if select_at_random:
pool = self._get_checkpoint_names(base_model)
if not pool:
raise FileNotFoundError(
f"No checkpoints found for base model '{base_model}'. "
"Pick a different base model or disable 'select_at_random'."
)
ckpt_name = random.choice(pool)
logger.info(
f"[RandomCheckpointLoaderLM] Randomly selected checkpoint: {ckpt_name}"
)
# Get absolute path from cache using ComfyUI-style name
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
if metadata is None:
raise FileNotFoundError(
f"Checkpoint '{ckpt_name}' not found in LoRA Manager cache. "
"Make sure the checkpoint is indexed and try again."
)
# Load regular checkpoint using ComfyUI's API
logger.info(f"Loading checkpoint from: {ckpt_path}")
out = comfy.sd.load_checkpoint_guess_config(
ckpt_path,
output_vae=True,
output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
)
return out[:3] + (ckpt_name,)
+326
View File
@@ -0,0 +1,326 @@
import logging
import os
import random
from typing import Any, List, Optional, Tuple
import comfy.sd # pyright: ignore[reportMissingImports]
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
def _reload_gguf_unet(
unet_path: str, weight_dtype: str, disable_dynamic: bool = False
) -> object:
"""Reload a GGUF diffusion model from disk (cached_patcher_init factory).
Mirrors the GGUF branch of RandomUNETLoaderLM.load_unet so ModelPatcher
deepclone/dynamic machinery can rebuild GGUF models with the correct
GGMLOps. ``disable_dynamic`` is accepted for signature compatibility
with core ComfyUI loaders.
"""
loader = RandomUNETLoaderLM()
model, _unet_name = loader._load_gguf_unet(unet_path, unet_path, weight_dtype)
return model
class RandomUNETLoaderLM:
"""UNET Loader that can randomly pick a diffusion model from the pool
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA
Manager's extra folder paths. Supports both regular diffusion models and
GGUF format models. When select_at_random is enabled, ignores unet_name
and picks a random diffusion model (optionally filtered by base_model)
on every run.
"""
NAME = "Random Unet Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(cls):
# Get list of unet names from scanner (includes extra folder paths)
unet_names = cls._get_unet_names()
base_models = cls._get_available_base_models()
return {
"required": {
"unet_name": (
unet_names,
{"tooltip": "The name of the diffusion model to load."},
),
"weight_dtype": (
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
{"tooltip": "The dtype to use for the model weights."},
),
"select_at_random": (
"BOOLEAN",
{
"default": False,
"tooltip": (
"Ignore unet_name and pick a random diffusion model from "
"the pool (optionally filtered by base_model) on every run."
),
},
),
"base_model": (
base_models,
{
"default": "Any",
"tooltip": "Restrict random selection to this base model. 'Any' uses the full pool.",
},
),
}
}
RETURN_TYPES = ("MODEL", "STRING")
RETURN_NAMES = ("MODEL", "model_name")
OUTPUT_TOOLTIPS = (
"The model used for denoising latents.",
"The name of the diffusion model that was loaded (useful when select_at_random is enabled).",
)
FUNCTION = "load_unet"
@classmethod
def IS_CHANGED(
cls, unet_name, weight_dtype, select_at_random=False, base_model="Any"
):
# Force re-execution on every run while randomizing, since the widget
# values themselves don't change between queue runs.
if select_at_random:
return float("nan")
return unet_name
@staticmethod
def _run_async(coro_fn):
"""Run an async fetcher, handling the case where an event loop is already running."""
import asyncio
try:
asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(coro_fn())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(coro_fn())
@classmethod
def _get_unet_names(cls, base_model: Optional[str] = None) -> List[str]:
"""Get list of diffusion model names from scanner cache in ComfyUI format (relative path with extension)
Args:
base_model: If given (and not "Any"), only include models matching this base model.
"""
try:
from ..services.service_registry import ServiceRegistry
async def _get_names():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get all model roots for calculating relative paths
model_roots = scanner.get_model_roots()
# Filter only diffusion_model type and format names
names = []
for item in cache.raw_data:
if item.get("sub_type") != "diffusion_model":
continue
if (
base_model
and base_model != "Any"
and item.get("base_model") != base_model
):
continue
file_path = item.get("file_path", "")
# Only offer models that still exist on disk so ComfyUI
# flags missing diffusion models at queue time via
# "value not in list" (the scanner cache can be stale).
if file_path and os.path.exists(file_path):
# Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui(
file_path, model_roots
)
if formatted_name:
names.append(formatted_name)
return sorted(names)
return cls._run_async(_get_names)
except Exception as e:
logger.error(f"Error getting unet names: {e}")
return []
@classmethod
def _get_available_base_models(cls) -> List[str]:
"""Get distinct base_model values present among indexed diffusion models, for the random-selection filter."""
try:
from ..services.service_registry import ServiceRegistry
async def _get_base_models():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
base_models = set()
for item in cache.raw_data:
if item.get("sub_type") != "diffusion_model":
continue
base_model = item.get("base_model")
file_path = item.get("file_path", "")
if base_model and file_path and os.path.exists(file_path):
base_models.add(base_model)
return sorted(base_models)
return ["Any"] + cls._run_async(_get_base_models)
except Exception as e:
logger.error(f"Error getting available base models: {e}")
return ["Any"]
def load_unet(
self,
unet_name: str,
weight_dtype: str,
select_at_random: bool = False,
base_model: str = "Any",
) -> Tuple[Any, ...]:
"""Load a diffusion model by name, supporting extra folder paths
Args:
unet_name: The name of the diffusion model to load (relative path with extension)
weight_dtype: The dtype to use for model weights
select_at_random: If True, ignore unet_name and pick randomly from the pool
base_model: Restricts random selection to this base model ("Any" = no filter)
Returns:
Tuple of (MODEL, model_name)
"""
import torch
if select_at_random:
pool = self._get_unet_names(base_model)
if not pool:
raise FileNotFoundError(
f"No diffusion models found for base model '{base_model}'. "
"Pick a different base model or disable 'select_at_random'."
)
unet_name = random.choice(pool)
logger.info(
f"[RandomUNETLoaderLM] Randomly selected diffusion model: {unet_name}"
)
# Get absolute path from cache using ComfyUI-style name
unet_path, metadata = get_checkpoint_info_absolute(unet_name)
if metadata is None:
raise FileNotFoundError(
f"Diffusion model '{unet_name}' not found in LoRA Manager cache. "
"Make sure the model is indexed and try again."
)
# Check if it's a GGUF model
if unet_path.endswith(".gguf"):
return self._load_gguf_unet(unet_path, unet_name, weight_dtype)
# Load regular diffusion model using ComfyUI's API
logger.info(f"Loading diffusion model from: {unet_path}")
# Build model options based on weight_dtype
model_options = {}
if weight_dtype == "fp8_e4m3fn":
model_options["dtype"] = torch.float8_e4m3fn
elif weight_dtype == "fp8_e4m3fn_fast":
model_options["dtype"] = torch.float8_e4m3fn
model_options["fp8_optimizations"] = True
elif weight_dtype == "fp8_e5m2":
model_options["dtype"] = torch.float8_e5m2
model = comfy.sd.load_diffusion_model(unet_path, model_options=model_options)
return (model, unet_name)
def _load_gguf_unet(
self, unet_path: str, unet_name: str, weight_dtype: str
) -> Tuple[Any, ...]:
"""Load a GGUF format diffusion model
Args:
unet_path: Absolute path to the GGUF file
unet_name: Name of the model for error messages
weight_dtype: The dtype to use for model weights
Returns:
Tuple of (MODEL, model_name)
"""
import torch
from .gguf_import_helper import get_gguf_modules
# Get ComfyUI-GGUF modules using helper (handles various import scenarios)
try:
loader_module, ops_module, nodes_module = get_gguf_modules()
gguf_sd_loader = getattr(loader_module, "gguf_sd_loader")
GGMLOps = getattr(ops_module, "GGMLOps")
GGUFModelPatcher = getattr(nodes_module, "GGUFModelPatcher")
except RuntimeError as e:
raise RuntimeError(f"Cannot load GGUF model '{unet_name}'. {str(e)}")
logger.info(f"Loading GGUF diffusion model from: {unet_path}")
try:
# Load GGUF state dict
sd, extra = gguf_sd_loader(unet_path)
# Prepare kwargs for metadata if supported
kwargs = {}
import inspect
valid_params = inspect.signature(
comfy.sd.load_diffusion_model_state_dict
).parameters
if "metadata" in valid_params:
kwargs["metadata"] = extra.get("metadata", {})
# Setup custom operations with GGUF support
ops = GGMLOps()
# Handle weight_dtype for GGUF models
if weight_dtype in ("default", None):
ops.Linear.dequant_dtype = None
elif weight_dtype in ["target"]:
ops.Linear.dequant_dtype = weight_dtype
else:
ops.Linear.dequant_dtype = getattr(torch, weight_dtype, None)
# Load the model
model = comfy.sd.load_diffusion_model_state_dict(
sd, model_options={"custom_operations": ops}, **kwargs
)
if model is None:
raise RuntimeError(
f"Could not detect model type for GGUF diffusion model: {unet_path}"
)
# Wrap with GGUFModelPatcher
model = GGUFModelPatcher.clone(model)
# Register a reload factory so the MODEL carries its source path
# (cached_patcher_init) like core ComfyUI loaders do — required
# for model-name extraction downstream and for ModelPatcher
# deepclone/dynamic machinery.
model.cached_patcher_init = (_reload_gguf_unet, (unet_path, weight_dtype))
return (model, unet_name)
except Exception as e:
logger.error(f"Error loading GGUF diffusion model '{unet_name}': {e}")
raise RuntimeError(
f"Failed to load GGUF diffusion model '{unet_name}': {str(e)}"
)
+7 -7
View File
@@ -5,7 +5,7 @@ import time
import uuid import uuid
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import numpy as np import numpy as np
import folder_paths # type: ignore import folder_paths # pyright: ignore[reportMissingImports]
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..metadata_collector.metadata_processor import MetadataProcessor from ..metadata_collector.metadata_processor import MetadataProcessor
from ..metadata_collector import get_metadata from ..metadata_collector import get_metadata
@@ -13,7 +13,7 @@ from ..utils.constants import CARD_PREVIEW_WIDTH
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from ..utils.utils import calculate_recipe_fingerprint, sanitize_folder_name from ..utils.utils import calculate_recipe_fingerprint, sanitize_folder_name
from PIL import Image, PngImagePlugin from PIL import Image, PngImagePlugin
import piexif import piexif # pyright: ignore[reportMissingTypeStubs]
import logging import logging
# Civitai-compatible sampler name mapping: ComfyUI internal → A1111 display name # Civitai-compatible sampler name mapping: ComfyUI internal → A1111 display name
@@ -355,7 +355,7 @@ class SaveImageLM:
type_lower = model_type.lower() if model_type else "other" type_lower = model_type.lower() if model_type else "other"
return f"urn:air:{slug}:{type_lower}:civitai:{model_id}@{version_id}" return f"urn:air:{slug}:{type_lower}:civitai:{model_id}@{version_id}"
def format_metadata(self, metadata_dict: dict, add_loras_to_prompt: bool = False) -> str: def format_metadata(self, metadata_dict: dict[str, Any], add_loras_to_prompt: bool = False) -> str:
"""Format metadata as A1111-compatible parameters string with Hashes JSON and Civitai resources.""" """Format metadata as A1111-compatible parameters string with Hashes JSON and Civitai resources."""
if not metadata_dict: return "" if not metadata_dict: return ""
@@ -396,7 +396,7 @@ class SaveImageLM:
ckpt_display_name = os.path.splitext(os.path.basename(checkpoint))[0] ckpt_display_name = os.path.splitext(os.path.basename(checkpoint))[0]
# Resolve LoRA hash and Civitai data from local cache # Resolve LoRA hash and Civitai data from local cache
loras_data: list[dict] = [] loras_data: list[dict[str, Any]] = []
for lora_name, strength in lora_entries: for lora_name, strength in lora_entries:
lora_hash, lora_civitai, lora_base_model = self._resolve_model_cache_entry( lora_hash, lora_civitai, lora_base_model = self._resolve_model_cache_entry(
"lora_scanner", lora_name "lora_scanner", lora_name
@@ -418,9 +418,9 @@ class SaveImageLM:
hashes[f"LORA:{lora['name']}"] = lora["hash"][:10].upper() hashes[f"LORA:{lora['name']}"] = lora["hash"][:10].upper()
# Build Civitai resources JSON array # Build Civitai resources JSON array
civitai_resources: list[dict] = [] civitai_resources: list[dict[str, Any]] = []
if ckpt_civitai.get("id", 0) > 0: if ckpt_civitai.get("id", 0) > 0:
ckpt_resource: dict = {} ckpt_resource: dict[str, Any] = {}
ckpt_type = (ckpt_civitai.get("model") or {}).get("type", "Checkpoint") ckpt_type = (ckpt_civitai.get("model") or {}).get("type", "Checkpoint")
model_id = ckpt_civitai.get("modelId", 0) model_id = ckpt_civitai.get("modelId", 0)
version_id = ckpt_civitai.get("id", 0) version_id = ckpt_civitai.get("id", 0)
@@ -439,7 +439,7 @@ class SaveImageLM:
lora_civitai = lora["civitai"] lora_civitai = lora["civitai"]
if not lora_civitai or lora_civitai.get("id", 0) <= 0: if not lora_civitai or lora_civitai.get("id", 0) <= 0:
continue continue
lora_resource: dict = {"weight": lora["strength"]} lora_resource: dict[str, Any] = {"weight": lora["strength"]}
lora_type = (lora_civitai.get("model") or {}).get("type", "LORA") lora_type = (lora_civitai.get("model") or {}).get("type", "LORA")
model_id = lora_civitai.get("modelId", 0) model_id = lora_civitai.get("modelId", 0)
version_id = lora_civitai.get("id", 0) version_id = lora_civitai.get("id", 0)
+86 -8
View File
@@ -1,7 +1,7 @@
import logging import logging
import os import os
from typing import List, Tuple from typing import Any, List, Tuple
import comfy.sd # type: ignore import comfy.sd # pyright: ignore[reportMissingImports]
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,25 +28,47 @@ class UNETLoaderLM:
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA Manager's Loads diffusion models/UNets from both standard ComfyUI folders and LoRA Manager's
extra folder paths, providing a unified interface for UNET loading. extra folder paths, providing a unified interface for UNET loading.
Supports both regular diffusion models and GGUF format models. Supports both regular diffusion models and GGUF format models.
The unet_name combo supports ComfyUI's control_after_generate, letting
users pick a random diffusion model on every run; the base_model input
narrows the random pool through a front-end extension that filters the
combo options.
""" """
NAME = "Unet Loader (LoraManager)" NAME = "Unet Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders" CATEGORY = "Lora Manager/loaders"
@classmethod @classmethod
def INPUT_TYPES(s): def INPUT_TYPES(cls):
# Get list of unet names from scanner (includes extra folder paths) # Get list of unet names from scanner (includes extra folder paths)
unet_names = s._get_unet_names() unet_names = cls._get_unet_names()
base_models = cls._get_available_base_models()
return { return {
"required": { "required": {
"unet_name": ( "unet_name": (
unet_names, unet_names,
{"tooltip": "The name of the diffusion model to load."}, {
"tooltip": (
"The name of the diffusion model to load. Use "
"control_after_generate to pick a random model on "
"every run."
),
"control_after_generate": "fixed",
},
), ),
"weight_dtype": ( "weight_dtype": (
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"], ["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
{"tooltip": "The dtype to use for the model weights."}, {"tooltip": "The dtype to use for the model weights."},
), ),
"base_model": (
base_models,
{
"default": "Any",
"tooltip": (
"Restrict the random selection pool to this base "
"model. 'Any' uses the full pool."
),
},
),
} }
} }
@@ -74,7 +96,10 @@ class UNETLoaderLM:
for item in cache.raw_data: for item in cache.raw_data:
if item.get("sub_type") == "diffusion_model": if item.get("sub_type") == "diffusion_model":
file_path = item.get("file_path", "") file_path = item.get("file_path", "")
if file_path: # Only offer models that still exist on disk so ComfyUI
# flags missing diffusion models at queue time via
# "value not in list" (the scanner cache can be stale).
if file_path and os.path.exists(file_path):
# Format using relative path with OS-native separator # Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui( formatted_name = _format_model_name_for_comfyui(
file_path, model_roots file_path, model_roots
@@ -105,16 +130,69 @@ class UNETLoaderLM:
logger.error(f"Error getting unet names: {e}") logger.error(f"Error getting unet names: {e}")
return [] return []
def load_unet(self, unet_name: str, weight_dtype: str) -> Tuple: @classmethod
def _get_available_base_models(cls) -> List[str]:
"""Get distinct base_model values present among indexed diffusion models, for the random-selection filter."""
try:
from ..services.service_registry import ServiceRegistry
async def _get_base_models():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
base_models = set()
for item in cache.raw_data:
if item.get("sub_type") != "diffusion_model":
continue
base_model = item.get("base_model")
file_path = item.get("file_path", "")
if base_model and file_path and os.path.exists(file_path):
base_models.add(base_model)
return sorted(base_models)
return ["Any"] + cls._run_async(_get_base_models)
except Exception as e:
logger.error(f"Error getting available base models: {e}")
return ["Any"]
@staticmethod
def _run_async(coro_fn):
"""Run an async fetcher, handling the case where an event loop is already running."""
import asyncio
try:
asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(coro_fn())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(coro_fn())
def load_unet(
self, unet_name: str, weight_dtype: str, base_model: str = "Any"
) -> Tuple[Any, ...]:
"""Load a diffusion model by name, supporting extra folder paths """Load a diffusion model by name, supporting extra folder paths
Args: Args:
unet_name: The name of the diffusion model to load (relative path with extension) unet_name: The name of the diffusion model to load (relative path with extension)
weight_dtype: The dtype to use for model weights weight_dtype: The dtype to use for model weights
base_model: Only used by the front-end to filter the random pool
Returns: Returns:
Tuple of (MODEL,) Tuple of (MODEL,)
""" """
del base_model
import torch import torch
# Get absolute path from cache using ComfyUI-style name # Get absolute path from cache using ComfyUI-style name
@@ -148,7 +226,7 @@ class UNETLoaderLM:
def _load_gguf_unet( def _load_gguf_unet(
self, unet_path: str, unet_name: str, weight_dtype: str self, unet_path: str, unet_name: str, weight_dtype: str
) -> Tuple: ) -> Tuple[Any, ...]:
"""Load a GGUF format diffusion model """Load a GGUF format diffusion model
Args: Args:
+159 -3
View File
@@ -1,3 +1,6 @@
from typing import Any
class AnyType(str): class AnyType(str):
"""A special class that is always equal in not equal comparisons. Credit to pythongosssss""" """A special class that is always equal in not equal comparisons. Credit to pythongosssss"""
@@ -6,7 +9,7 @@ class AnyType(str):
# Credit to Regis Gaughan, III (rgthree) # Credit to Regis Gaughan, III (rgthree)
class FlexibleOptionalInputType(dict): class FlexibleOptionalInputType(dict[str, Any]):
"""A special class to make flexible nodes that pass data to our python handlers. """A special class to make flexible nodes that pass data to our python handlers.
Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs
@@ -23,6 +26,7 @@ class FlexibleOptionalInputType(dict):
""" """
def __init__(self, type): def __init__(self, type):
super().__init__()
self.type = type self.type = type
def __getitem__(self, key): def __getitem__(self, key):
@@ -40,7 +44,8 @@ import re
import logging import logging
import copy import copy
import sys import sys
import folder_paths # type: ignore import asyncio
import folder_paths # pyright: ignore[reportMissingImports]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -70,7 +75,7 @@ def extract_lora_name(lora_path):
return apply_lora_syntax_format(name_no_ext) return apply_lora_syntax_format(name_no_ext)
def parse_lora_syntax(text: str) -> list[dict]: def parse_lora_syntax(text: str) -> list[dict[str, Any]]:
"""Parse <lora:name:strength> syntax from text input into a list of dicts. """Parse <lora:name:strength> syntax from text input into a list of dicts.
Each entry contains: name, model_strength, clip_strength. Each entry contains: name, model_strength, clip_strength.
@@ -107,6 +112,157 @@ def get_loras_list(kwargs):
return [] return []
_LORA_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".bin")
def _strip_lora_extension(name: str) -> str:
"""Strip a known LoRA model extension from a name (case-insensitive)."""
lowered = name.lower()
for ext in _LORA_EXTENSIONS:
if lowered.endswith(ext):
return name[: -len(ext)]
return name
def _find_missing_loras(names: list[str]) -> list[str]:
"""Return the names that cannot be resolved to an existing local LoRA file.
Mirrors the matching semantics of ``get_lora_info_absolute``
(py/utils/utils.py): after stripping the extension, a name matches a cached
LoRA when it equals the cached file name or the ``folder/file`` path. As a
fallback, a name containing a folder that only matches by basename resolves
to the first basename match (same behavior as the runtime resolver). Raw
absolute paths that exist on disk are always considered available.
The scanner cache is fetched once for all names; the cache may be stale, so
resolved paths are additionally verified with ``os.path.isfile``.
"""
if not names:
return []
async def _check() -> list[str]:
from ..services.service_registry import ServiceRegistry
scanner = await ServiceRegistry.get_lora_scanner()
# The scanner cache may not be hydrated yet (startup, library path
# change). An empty cache is not authoritative — treat it as "cannot
# verify" and skip validation instead of flagging every active LoRA
# as missing.
if getattr(scanner, "_cache", None) is None or getattr(
scanner, "_is_initializing", False
):
return []
cache = await scanner.get_cached_data()
lookup = {}
basename_candidates = {}
for item in cache.raw_data:
file_path = item.get("file_path")
if not file_path:
continue
file_name = item.get("file_name", "")
folder = item.get("folder", "")
file_name_no_ext = _strip_lora_extension(file_name)
path_name_no_ext = (
f"{folder}/{file_name_no_ext}".replace("\\", "/")
if folder
else file_name_no_ext
)
lookup.setdefault(file_name_no_ext, file_path)
lookup.setdefault(path_name_no_ext, file_path)
basename_candidates.setdefault(file_name_no_ext, []).append(
(folder, file_path)
)
missing = []
for name in names:
if not name:
continue
normalized = name.replace("\\", "/")
# Raw absolute paths (outside the library) are usable as-is.
if os.path.isfile(normalized):
continue
no_ext = _strip_lora_extension(normalized)
file_path = lookup.get(no_ext)
if file_path is None and "/" in no_ext:
# A name with a folder that matches only by basename resolves
# at runtime like get_lora_info_absolute's fallback does:
# prefer a candidate whose folder prefixes the name, else the
# first basename match.
folder, basename = no_ext.rsplit("/", 1)
candidates = basename_candidates.get(basename, [])
file_path = next(
(
fp
for fld, fp in candidates
if fld and no_ext.startswith(fld + "/")
),
None,
)
if file_path is None and candidates:
file_path = candidates[0][1]
if file_path is None or not os.path.isfile(file_path):
missing.append(name)
return missing
try:
# Check if we're already in an event loop
loop = asyncio.get_running_loop()
# If we're in a running loop, run the async check in a separate thread
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(_check())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
# No event loop is running, we can use asyncio.run()
return asyncio.run(_check())
def validate_lora_entries(kwargs):
"""Validate active LoRA widget entries against the local library.
Used by node ``VALIDATE_INPUTS`` implementations so ComfyUI rejects the
prompt at queue time (``custom_validation_failed``) when an active entry
references a LoRA that is not available locally mirroring how built-in
loader nodes flag missing models before execution starts.
Returns:
None when every active entry resolves to an existing local file,
otherwise a descriptive error string listing the missing LoRAs.
Verification failures (e.g. scanner not ready) are treated as valid
so queueing is never blocked by validation machinery itself.
"""
# Missing/empty loras input is always valid; skip get_loras_list so it
# does not log a warning for the None case on every queue.
if not kwargs.get("loras"):
return None
loras = get_loras_list(kwargs)
active_names = []
for lora in loras:
if not isinstance(lora, dict):
continue
if not lora.get("active", False):
continue
active_names.append(apply_lora_syntax_format(str(lora.get("name") or "")))
try:
missing = _find_missing_loras(active_names)
except Exception:
logger.exception("Failed to validate LoRA entries against the local library")
return None
if not missing:
return None
return "Missing LoRA(s) in local library: " + ", ".join(missing)
def load_state_dict_in_safetensors(path, device="cpu", filter_prefix=""): def load_state_dict_in_safetensors(path, device="cpu", filter_prefix=""):
"""Simplified version of load_state_dict_in_safetensors that just loads from a local path""" """Simplified version of load_state_dict_in_safetensors that just loads from a local path"""
import safetensors.torch import safetensors.torch
+10 -4
View File
@@ -1,7 +1,7 @@
import os import os
from ..utils.utils import get_lora_info_absolute from ..utils.utils import get_lora_info_absolute
from ..config import config from ..config import config
from .utils import FlexibleOptionalInputType, any_type, get_loras_list from .utils import FlexibleOptionalInputType, any_type, get_loras_list, validate_lora_entries
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -31,15 +31,21 @@ class WanVideoLoraSelectLM:
"placeholder": "Search LoRAs to add...", "placeholder": "Search LoRAs to add...",
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation", "tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
}), }),
"loras": ("LORAS", {}),
}, },
"optional": FlexibleOptionalInputType(any_type), "optional": FlexibleOptionalInputType(any_type),
} }
@classmethod
def VALIDATE_INPUTS(cls, loras=None):
"""Queue-time validation: reject missing local LoRAs before execution."""
return validate_lora_entries({"loras": loras}) or True
RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING") RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING")
RETURN_NAMES = ("lora", "trigger_words", "active_loras") RETURN_NAMES = ("lora", "trigger_words", "active_loras")
FUNCTION = "process_loras" FUNCTION = "process_loras"
def process_loras(self, text, low_mem_load=False, merge_loras=True, **kwargs): def process_loras(self, text, loras, low_mem_load=False, merge_loras=True, **kwargs):
loras_list = [] loras_list = []
all_trigger_words = [] all_trigger_words = []
active_loras = [] active_loras = []
@@ -57,8 +63,8 @@ class WanVideoLoraSelectLM:
selected_blocks = blocks.get("selected_blocks", {}) selected_blocks = blocks.get("selected_blocks", {})
layer_filter = blocks.get("layer_filter", "") layer_filter = blocks.get("layer_filter", "")
# Process loras from kwargs with support for both old and new formats # Process loras from the widget with support for both old and new formats
loras_from_widget = get_loras_list(kwargs) loras_from_widget = get_loras_list({"loras": loras})
for lora in loras_from_widget: for lora in loras_from_widget:
if not lora.get('active', False): if not lora.get('active', False):
continue continue
+64 -8
View File
@@ -1,3 +1,7 @@
# pyright: reportImportCycles=false
# Lazy (function-local) imports still count as static edges in basedpyright's
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
"""Base classes for recipe parsers.""" """Base classes for recipe parsers."""
import json import json
@@ -7,7 +11,7 @@ import re
from typing import Dict, List, Any, Optional, Tuple from typing import Dict, List, Any, Optional, Tuple
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from ..config import config from ..config import config
from ..utils.constants import VALID_LORA_TYPES, VALID_CHECKPOINT_SUB_TYPES from ..utils.constants import MODEL_WEIGHT_FILE_TYPES, VALID_LORA_TYPES, VALID_CHECKPOINT_SUB_TYPES
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import rewrite_preview_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,7 +42,41 @@ class RecipeMetadataParser(ABC):
pass pass
@staticmethod @staticmethod
async def populate_lora_from_civitai(lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any], Optional[str]], def populate_lora_from_local(lora_entry: Dict[str, Any], local_lora: Dict[str, Any], base_model_counts=None) -> Dict[str, Any]:
"""Populate a recipe LoRA entry from the local scanner cache."""
local_path = local_lora.get('file_path') or ''
file_name = local_lora.get('file_name') or os.path.splitext(os.path.basename(local_path))[0]
base_model = local_lora.get('base_model') or ''
lora_entry['name'] = local_lora.get('model_name') or file_name or lora_entry.get('name', '')
lora_entry['file_name'] = file_name
lora_entry['hash'] = (local_lora.get('sha256') or lora_entry.get('hash') or '').lower()
lora_entry['localPath'] = local_path or None
lora_entry['size'] = local_lora.get('size', 0) or 0
lora_entry['baseModel'] = base_model
lora_entry['existsLocally'] = True
lora_entry['isDeleted'] = False
preview_url = local_lora.get('preview_url')
if preview_url:
lora_entry['thumbnailUrl'] = config.get_preview_static_url(preview_url)
civitai_info = local_lora.get('civitai') or {}
if isinstance(civitai_info, dict):
if civitai_info.get('id') is not None:
lora_entry['id'] = civitai_info['id']
if civitai_info.get('modelId') is not None:
lora_entry['modelId'] = civitai_info['modelId']
if civitai_info.get('name'):
lora_entry['version'] = civitai_info['name']
if base_model_counts is not None and base_model:
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
return lora_entry
@staticmethod
async def populate_lora_from_civitai(lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any] | None, str | None] | Dict[str, Any],
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]: recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
""" """
Populate a lora entry with information from Civitai API response Populate a lora entry with information from Civitai API response
@@ -151,9 +189,9 @@ class RecipeMetadataParser(ABC):
# Process file information if available # Process file information if available
if 'files' in civitai_info: if 'files' in civitai_info:
# Find the primary model file (type="Model" and primary=true) in the files list # Find the primary model file (weights-type and primary=true) in the files list
model_file = next((file for file in civitai_info.get('files', []) model_file = next((file for file in civitai_info.get('files', [])
if file.get('type') == 'Model' and file.get('primary') == True), None) if file.get('type') in MODEL_WEIGHT_FILE_TYPES and file.get('primary') == True), None)
if model_file: if model_file:
# Get size # Get size
@@ -175,10 +213,18 @@ class RecipeMetadataParser(ABC):
lora_entry['localPath'] = local_path lora_entry['localPath'] = local_path
lora_entry['file_name'] = os.path.splitext(os.path.basename(local_path))[0] lora_entry['file_name'] = os.path.splitext(os.path.basename(local_path))[0]
# Get thumbnail from local preview if available # Get thumbnail from local preview if available.
# Match the cache item by local path first (get_path_by_hash
# cascade: 10-char autov2 / 12-char autov3), then by hash.
lora_cache = await lora_scanner.get_cached_data() lora_cache = await lora_scanner.get_cached_data()
h = (lora_entry.get("hash") or "").lower()
lora_item = next((item for item in lora_cache.raw_data lora_item = next((item for item in lora_cache.raw_data
if item['sha256'].lower() == lora_entry['hash'].lower()), None) if (item.get("file_path") or "") == local_path), None)
if lora_item is None:
lora_item = next((item for item in lora_cache.raw_data
if (item.get("sha256") or "").lower() == h
or (item.get("autov3") or "").lower() == h
or (item.get("sha256") or "")[:10].lower() == h), None)
if lora_item and 'preview_url' in lora_item: if lora_item and 'preview_url' in lora_item:
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url']) lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
except Exception as e: except Exception as e:
@@ -194,7 +240,7 @@ class RecipeMetadataParser(ABC):
return lora_entry return lora_entry
@staticmethod @staticmethod
async def populate_checkpoint_from_civitai(checkpoint: Dict[str, Any], civitai_info: Dict[str, Any]) -> Dict[str, Any]: async def populate_checkpoint_from_civitai(checkpoint: Dict[str, Any], civitai_info: Dict[str, Any] | Tuple[Dict[str, Any] | None, str | None] | None) -> Dict[str, Any]:
""" """
Populate checkpoint information from Civitai API response Populate checkpoint information from Civitai API response
@@ -249,11 +295,21 @@ class RecipeMetadataParser(ABC):
checkpoint['id'] = civitai_data.get('id', 0) checkpoint['id'] = civitai_data.get('id', 0)
if 'files' in civitai_data: if 'files' in civitai_data:
# Prefer the file CivitAI marked primary; fall back to any
# weights-type file (providers without primary flags).
model_file = next( model_file = next(
( (
file file
for file in civitai_data.get('files', []) for file in civitai_data.get('files', [])
if file.get('type') == 'Model' if file.get('type') in MODEL_WEIGHT_FILE_TYPES
and file.get('primary') is True
),
None,
) or next(
(
file
for file in civitai_data.get('files', [])
if file.get('type') in MODEL_WEIGHT_FILE_TYPES
), ),
None, None,
) )
+4
View File
@@ -1,3 +1,7 @@
# pyright: reportImportCycles=false
# Lazy (function-local) imports still count as static edges in basedpyright's
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
import logging import logging
import json import json
import os import os
+3 -1
View File
@@ -1,6 +1,7 @@
"""Factory for creating recipe metadata parsers.""" """Factory for creating recipe metadata parsers."""
import logging import logging
from typing import Any
from .parsers import ( from .parsers import (
RecipeFormatParser, RecipeFormatParser,
ComfyMetadataParser, ComfyMetadataParser,
@@ -31,7 +32,8 @@ class RecipeParserFactory:
# First, try CivitaiApiMetadataParser for dict input # First, try CivitaiApiMetadataParser for dict input
if isinstance(metadata, dict): if isinstance(metadata, dict):
try: try:
if CivitaiApiMetadataParser().is_metadata_matching(metadata): user_comment: Any = metadata
if CivitaiApiMetadataParser().is_metadata_matching(user_comment):
return CivitaiApiMetadataParser() return CivitaiApiMetadataParser()
except Exception as e: except Exception as e:
logger.debug(f"CivitaiApiMetadataParser check failed: {e}") logger.debug(f"CivitaiApiMetadataParser check failed: {e}")
+171 -31
View File
@@ -52,7 +52,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
negative_and_params = "" negative_and_params = ""
# Initialize metadata # Initialize metadata
metadata = { metadata: Dict[str, Any] = {
"prompt": prompt, "prompt": prompt,
"loras": [] "loras": []
} }
@@ -362,37 +362,47 @@ class AutomaticMetadataParser(RecipeMetadataParser):
checkpoint = checkpoint_entry checkpoint = checkpoint_entry
# If no LoRAs from Civitai resources or to supplement, extract from metadata["hashes"] def normalize_lora_name(name, basename=False):
if not loras or len(loras) == 0: normalized = str(name or '').replace('\\', '/')
# Extract lora weights from extranet tags in prompt (for later use) if normalized.casefold().endswith('.safetensors'):
lora_weights = {} normalized = normalized[:-12]
lora_matches = re.findall(self.EXTRANETS_REGEX, prompt) if basename:
for lora_type, lora_name, lora_weight in lora_matches: normalized = normalized.rsplit('/', 1)[-1]
key = f"{lora_type}:{lora_name}" return normalized.casefold()
lora_weights[key] = round(float(lora_weight), 2)
# Use hashes from metadata as the primary source def get_version_id(lora):
if metadata.get("hashes"): version_id = lora.get('id')
for hash_key, lora_hash in metadata.get("hashes", {}).items(): if version_id in (None, '', 0, '0'):
# Only process lora or hypernet types version_id = lora.get('modelVersionId')
if not hash_key.startswith(("lora:", "hypernet:")): if version_id in (None, '', 0, '0'):
continue return None
return str(version_id)
# Skip entries without a hash value — they can't be prompt_loras = {}
# resolved via CivitAI and would only produce a for match in re.findall(self.EXTRANETS_REGEX, prompt):
# useless "Deleted" entry in the recipe. lora_type, lora_name, _ = match
if not lora_hash: prompt_loras[(lora_type, normalize_lora_name(lora_name))] = match
continue
lora_type, lora_name = hash_key.split(':', 1) prompt_by_basename = {}
for lora_type, lora_name, lora_weight in prompt_loras.values():
key = (lora_type, normalize_lora_name(lora_name, True))
prompt_by_basename.setdefault(key, []).append((lora_name, round(float(lora_weight), 2)))
# Get weight from extranet tags if available, else default to 1.0 hash_basenames = {
weight = lora_weights.get(hash_key, 1.0) (hash_key.split(':', 1)[0], normalize_lora_name(hash_key.split(':', 1)[1], True))
for hash_key, hash_value in metadata.get("hashes", {}).items()
if hash_value and hash_key.startswith(("lora:", "hypernet:"))
}
recipe_base_model = checkpoint.get("baseModel") if checkpoint else None
if not recipe_base_model and len(base_model_counts) == 1:
recipe_base_model = next(iter(base_model_counts))
# Initialize lora entry resource_lora_count = len(loras)
lora_entry = {
def make_lora_entry(lora_type, lora_name, weight, lora_hash=''):
return {
'name': lora_name, 'name': lora_name,
'type': lora_type, # 'lora' or 'hypernet' 'type': lora_type,
'weight': weight, 'weight': weight,
'hash': lora_hash, 'hash': lora_hash,
'existsLocally': False, 'existsLocally': False,
@@ -405,24 +415,154 @@ class AutomaticMetadataParser(RecipeMetadataParser):
'isDeleted': False 'isDeleted': False
} }
# Try to get info from Civitai def merge_or_append_civitai(civitai_entry, preserve_existing_weight=False):
if metadata_provider: civitai_id = get_version_id(civitai_entry)
civitai_hash = (civitai_entry.get('hash') or '').lower()
for index, existing in enumerate(loras):
existing_id = get_version_id(existing)
existing_hash = (existing.get('hash') or '').lower()
if not (
(civitai_id and existing_id == civitai_id)
or (civitai_hash and existing_hash == civitai_hash)
):
continue
if preserve_existing_weight:
civitai_entry['weight'] = existing.get('weight', civitai_entry['weight'])
existing_base = existing.get('baseModel')
if not civitai_entry.get('baseModel'):
civitai_entry['baseModel'] = existing_base or ''
elif existing_base:
remaining = base_model_counts.get(existing_base, 0) - 1
if remaining > 0:
base_model_counts[existing_base] = remaining
else:
base_model_counts.pop(existing_base, None)
loras[index] = civitai_entry
return
loras.append(civitai_entry)
def merge_or_append_local(local_entry):
local_id = get_version_id(local_entry)
local_hash = (local_entry.get('hash') or '').lower()
for existing in loras:
existing_id = get_version_id(existing)
existing_hash = (existing.get('hash') or '').lower()
if not (
(local_id and existing_id == local_id)
or (local_hash and existing_hash == local_hash)
):
continue
existing['weight'] = local_entry['weight']
existing['hash'] = local_entry['hash']
existing['file_name'] = local_entry['file_name']
existing['existsLocally'] = True
existing['localPath'] = local_entry['localPath']
existing['size'] = local_entry['size']
existing['isDeleted'] = False
if not existing.get('modelId') and local_entry.get('modelId'):
existing['modelId'] = local_entry['modelId']
if not existing.get('baseModel') and local_entry.get('baseModel'):
existing['baseModel'] = local_entry['baseModel']
base_model_counts[local_entry['baseModel']] = base_model_counts.get(local_entry['baseModel'], 0) + 1
thumbnail_url = local_entry.get('thumbnailUrl')
if thumbnail_url and not thumbnail_url.endswith('/images/no-preview.png'):
existing['thumbnailUrl'] = thumbnail_url
return
if local_entry.get('baseModel'):
base_model = local_entry['baseModel']
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
loras.append(local_entry)
resolved_prompt_basenames = set()
queried_local_basenames = set()
for lora_type, lora_name, lora_weight in prompt_loras.values():
weight = round(float(lora_weight), 2)
basename_key = (lora_type, normalize_lora_name(lora_name, True))
matching_resources = [
lora
for lora in loras[:resource_lora_count]
if lora.get('file_name')
and normalize_lora_name(lora['file_name'], True) == basename_key[1]
and (
(lora_type == 'hypernet' and str(lora.get('type', '')).casefold() in ('hypernet', 'hypernetwork'))
or (lora_type == 'lora' and str(lora.get('type', '')).casefold() not in ('hypernet', 'hypernetwork'))
)
]
if len(prompt_by_basename[basename_key]) == 1 and len(matching_resources) == 1:
matching_resources[0]['weight'] = weight
if basename_key not in hash_basenames:
resolved_prompt_basenames.add(basename_key)
continue
if basename_key in hash_basenames:
continue
if not recipe_scanner or lora_type != 'lora':
continue
queried_local_basenames.add(basename_key)
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
if not local_lora:
continue
local_entry = self.populate_lora_from_local(
make_lora_entry(lora_type, lora_name, weight),
local_lora,
)
merge_or_append_local(local_entry)
resolved_prompt_basenames.add(basename_key)
for hash_key, lora_hash in metadata.get("hashes", {}).items():
if not hash_key.startswith(("lora:", "hypernet:")):
continue
lora_type, lora_name = hash_key.split(':', 1)
basename_key = (lora_type, normalize_lora_name(lora_name, True))
if basename_key in resolved_prompt_basenames:
continue
prompt_entries = prompt_by_basename.get(basename_key, [])
weight = prompt_entries[0][1] if len(prompt_entries) == 1 else 1.0
lora_entry = make_lora_entry(lora_type, lora_name, weight, lora_hash)
if lora_hash and recipe_scanner and lora_type == 'lora':
local_lora = await recipe_scanner.get_local_lora_by_hash(lora_hash)
if local_lora:
local_entry = self.populate_lora_from_local(lora_entry, local_lora)
merge_or_append_local(local_entry)
continue
hash_resolved = False
if lora_hash and metadata_provider:
try: try:
civitai_info = await metadata_provider.get_model_by_hash(lora_hash) civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
populated_entry = await self.populate_lora_from_civitai( populated_entry = await self.populate_lora_from_civitai(
lora_entry, lora_entry,
civitai_info, civitai_info,
recipe_scanner, recipe_scanner,
base_model_counts, base_model_counts,
lora_hash lora_hash,
) )
if populated_entry is None: if populated_entry is None:
continue # Skip invalid LoRA types continue
lora_entry = populated_entry lora_entry = populated_entry
hash_resolved = not lora_entry.get('isDeleted')
except Exception as e: except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA {lora_name}: {e}") logger.error(f"Error fetching Civitai info for LoRA {lora_name}: {e}")
if hash_resolved:
merge_or_append_civitai(lora_entry, preserve_existing_weight=not prompt_entries)
continue
if recipe_scanner and lora_type == 'lora' and basename_key not in queried_local_basenames:
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
if local_lora:
local_entry = self.populate_lora_from_local(lora_entry, local_lora)
merge_or_append_local(local_entry)
continue
if lora_hash and not resource_lora_count:
loras.append(lora_entry) loras.append(lora_entry)
# Try to get base model from resources or make educated guess # Try to get base model from resources or make educated guess
+74 -13
View File
@@ -4,7 +4,7 @@ import json
import logging import logging
from typing import Dict, Any, Union from typing import Dict, Any, Union
from ..base import RecipeMetadataParser from ..base import RecipeMetadataParser
from ..constants import GEN_PARAM_KEYS from ..constants import GEN_PARAM_KEYS, VALID_LORA_TYPES
from ...services.metadata_service import get_default_metadata_provider from ...services.metadata_service import get_default_metadata_provider
from ...config import config from ...config import config
@@ -14,15 +14,16 @@ logger = logging.getLogger(__name__)
class CivitaiApiMetadataParser(RecipeMetadataParser): class CivitaiApiMetadataParser(RecipeMetadataParser):
"""Parser for Civitai image metadata format""" """Parser for Civitai image metadata format"""
def is_metadata_matching(self, metadata) -> bool: def is_metadata_matching(self, user_comment) -> bool:
"""Check if the metadata matches the Civitai image metadata format """Check if the metadata matches the Civitai image metadata format
Args: Args:
metadata: The metadata from the image (dict) user_comment: The metadata from the image (dict)
Returns: Returns:
bool: True if this parser can handle the metadata bool: True if this parser can handle the metadata
""" """
metadata = user_comment
if not metadata or not isinstance(metadata, dict): if not metadata or not isinstance(metadata, dict):
return False return False
@@ -73,7 +74,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
return False return False
async def parse_metadata( # type: ignore[override] async def parse_metadata( # pyright: ignore[reportIncompatibleMethodOverride]
self, user_comment, recipe_scanner=None, civitai_client=None, self, user_comment, recipe_scanner=None, civitai_client=None,
local_cache: dict[str, Any] | None = None, local_cache: dict[str, Any] | None = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
@@ -89,8 +90,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
Returns: Returns:
Dict containing parsed recipe data Dict containing parsed recipe data
""" """
metadata: Dict[str, Any] = user_comment # type: ignore[assignment] metadata: Dict[str, Any] = user_comment
metadata = user_comment
try: try:
# Get metadata provider instead of using civitai_client directly # Get metadata provider instead of using civitai_client directly
metadata_provider = await get_default_metadata_provider() metadata_provider = await get_default_metadata_provider()
@@ -116,7 +116,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
metadata = inner_meta metadata = inner_meta
# Initialize result structure # Initialize result structure
result = { result: Dict[str, Any] = {
"base_model": None, "base_model": None,
"loras": [], "loras": [],
"model": None, "model": None,
@@ -125,10 +125,10 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
} }
# Track already added LoRAs to prevent duplicates # Track already added LoRAs to prevent duplicates
added_loras = {} # key: model_version_id or hash, value: index in result["loras"] added_loras: Dict[str, Any] = {} # key: model_version_id or hash, value: index in result["loras"]
# Extract hash information from hashes field for LoRA matching # Extract hash information from hashes field for LoRA matching
lora_hashes = {} lora_hashes: Dict[str, Any] = {}
if "hashes" in metadata and isinstance(metadata["hashes"], dict): if "hashes" in metadata and isinstance(metadata["hashes"], dict):
for key, hash_value in metadata["hashes"].items(): for key, hash_value in metadata["hashes"].items():
key_str = str(key) key_str = str(key)
@@ -184,7 +184,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if model_info: if model_info:
result["base_model"] = model_info.get("baseModel", "") result["base_model"] = model_info.get("baseModel", "")
base_model_counts = {} base_model_counts: Dict[str, int] = {}
# Process standard resources array # Process standard resources array
if "resources" in metadata and isinstance(metadata["resources"], list): if "resources" in metadata and isinstance(metadata["resources"], list):
@@ -196,7 +196,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# identification because it has an explicit type field and hash, # identification because it has an explicit type field and hash,
# unlike modelVersionIds which is a flat list with no type info. # unlike modelVersionIds which is a flat list with no type info.
if resource_type == "model": if resource_type == "model":
checkpoint_entry = { checkpoint_entry: Dict[str, Any] = {
"id": 0, "id": 0,
"modelId": 0, "modelId": 0,
"name": resource.get("name", "Unknown Model"), "name": resource.get("name", "Unknown Model"),
@@ -216,7 +216,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Try to look up base model from the checkpoint hash # Try to look up base model from the checkpoint hash
cp_hash = checkpoint_entry.get("hash") cp_hash = checkpoint_entry.get("hash")
if cp_hash and metadata_provider: if cp_hash and metadata_provider:
local_cached = local_cache.get(cp_hash) if local_cache else None # local_cache keys are stored lowercase
local_cached = local_cache.get(cp_hash.lower()) if local_cache else None
if local_cached: if local_cached:
self._populate_entry_from_cache( self._populate_entry_from_cache(
checkpoint_entry, local_cached checkpoint_entry, local_cached
@@ -294,8 +295,15 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Try to get info from Civitai if hash is available # Try to get info from Civitai if hash is available
if lora_hash and metadata_provider: if lora_hash and metadata_provider:
local_cached = local_cache.get(lora_hash) if local_cache else None # local_cache keys are stored lowercase
local_cached = local_cache.get(lora_hash.lower()) if local_cache else None
if local_cached: if local_cached:
cached_type = self._cache_item_model_type(local_cached)
if cached_type and cached_type not in VALID_LORA_TYPES:
logger.debug(
f"Skipping non-LoRA cache item for hash {lora_hash}"
)
continue
self._populate_entry_from_cache( self._populate_entry_from_cache(
lora_entry, local_cached lora_entry, local_cached
) )
@@ -304,6 +312,12 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
added_loras[str(lora_entry["id"])] = len( added_loras[str(lora_entry["id"])] = len(
result["loras"] result["loras"]
) )
# Mirror base.py:150-151 counts for API-path loras
bm = local_cached.get("base_model") or ""
if bm:
base_model_counts[bm] = base_model_counts.get(
bm, 0
) + 1
else: else:
try: try:
civitai_info = ( civitai_info = (
@@ -649,6 +663,23 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
} }
if metadata_provider: if metadata_provider:
# local_cache keys are stored lowercase
local_cached = local_cache.get(lora_hash.lower()) if local_cache else None
if local_cached:
cached_type = self._cache_item_model_type(local_cached)
if cached_type and cached_type not in VALID_LORA_TYPES:
logger.debug(
f"Skipping non-LoRA cache item for hash {lora_hash}"
)
continue
self._populate_entry_from_cache(lora_entry, local_cached)
# Mirror base.py:150-151 counts for API-path loras
bm = local_cached.get("base_model") or ""
if bm:
base_model_counts[bm] = base_model_counts.get(bm, 0) + 1
if "id" in lora_entry and lora_entry["id"]:
added_loras[str(lora_entry["id"])] = len(result["loras"])
else:
try: try:
civitai_info = await metadata_provider.get_model_by_hash( civitai_info = await metadata_provider.get_model_by_hash(
lora_hash lora_hash
@@ -711,6 +742,25 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Try to get info from Civitai if hash is available # Try to get info from Civitai if hash is available
if lora_entry["hash"] and metadata_provider: if lora_entry["hash"] and metadata_provider:
# local_cache keys are stored lowercase
local_cached = local_cache.get(lora_hash.lower()) if local_cache else None
if local_cached:
cached_type = self._cache_item_model_type(local_cached)
if cached_type and cached_type not in VALID_LORA_TYPES:
logger.debug(
f"Skipping non-LoRA cache item for hash {lora_hash}"
)
lora_index += 1
continue # Skip non-LoRA cache items
self._populate_entry_from_cache(lora_entry, local_cached)
# Mirror base.py:150-151 counts for API-path loras
bm = local_cached.get("base_model") or ""
if bm:
base_model_counts[bm] = base_model_counts.get(bm, 0) + 1
# If we have a version ID from Civitai, track it for deduplication
if "id" in lora_entry and lora_entry["id"]:
added_loras[str(lora_entry["id"])] = len(result["loras"])
else:
try: try:
civitai_info = await metadata_provider.get_model_by_hash( civitai_info = await metadata_provider.get_model_by_hash(
lora_hash lora_hash
@@ -795,3 +845,14 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
base_model = cache_item.get("base_model", "") base_model = cache_item.get("base_model", "")
if base_model: if base_model:
entry["baseModel"] = base_model entry["baseModel"] = base_model
@staticmethod
def _cache_item_model_type(cache_item: dict[str, Any]) -> str:
"""Lowercased civitai.model.type of a cache item, or '' when unknown."""
civ = cache_item.get("civitai")
if not isinstance(civ, dict):
return ""
model_info = civ.get("model")
if not isinstance(model_info, dict):
return ""
return (model_info.get("type") or "").lower()
+94 -67
View File
@@ -31,79 +31,15 @@ class ComfyMetadataParser(RecipeMetadataParser):
metadata_provider = await get_default_metadata_provider() metadata_provider = await get_default_metadata_provider()
data = json.loads(user_comment) data = json.loads(user_comment)
loras = []
# Find all LoraLoader nodes
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
# Process each LoraLoader node
for node_id, node in lora_nodes.items():
if 'inputs' not in node or 'lora_name' not in node['inputs']:
continue
lora_name = node['inputs'].get('lora_name', '')
# Parse the URN to extract model ID and version ID
# Format: "urn:air:sdxl:lora:civitai:1107767@1253442"
lora_id_match = re.search(r'civitai:(\d+)@(\d+)', lora_name)
if not lora_id_match:
continue
model_id = lora_id_match.group(1)
model_version_id = lora_id_match.group(2)
# Get strength from node inputs
weight = node['inputs'].get('strength_model', 1.0)
# Initialize lora entry with default values
lora_entry = {
'id': model_version_id,
'modelId': model_id,
'name': f"Lora {model_id}", # Default name
'version': '',
'type': 'lora',
'weight': weight,
'existsLocally': False,
'localPath': None,
'file_name': '',
'hash': '',
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
# Get additional info from Civitai if metadata provider is available
if metadata_provider:
try:
civitai_info_tuple = await metadata_provider.get_model_version_info(model_version_id)
# Populate lora entry with Civitai info
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info_tuple,
recipe_scanner
)
if populated_entry is None:
continue # Skip invalid LoRA types
lora_entry = populated_entry
except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA: {e}")
loras.append(lora_entry)
# Find checkpoint info
checkpoint_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'CheckpointLoaderSimple'} checkpoint_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'CheckpointLoaderSimple'}
checkpoint = None checkpoint = None
checkpoint_id = None checkpoint_id = None
checkpoint_version_id = None checkpoint_version_id = None
if checkpoint_nodes: if checkpoint_nodes:
# Get the first checkpoint node
checkpoint_node = next(iter(checkpoint_nodes.values())) checkpoint_node = next(iter(checkpoint_nodes.values()))
if 'inputs' in checkpoint_node and 'ckpt_name' in checkpoint_node['inputs']: if 'inputs' in checkpoint_node and 'ckpt_name' in checkpoint_node['inputs']:
checkpoint_name = checkpoint_node['inputs']['ckpt_name'] checkpoint_name = checkpoint_node['inputs']['ckpt_name']
# Parse checkpoint URN
checkpoint_match = re.search(r'civitai:(\d+)@(\d+)', checkpoint_name) checkpoint_match = re.search(r'civitai:(\d+)@(\d+)', checkpoint_name)
if checkpoint_match: if checkpoint_match:
checkpoint_id = checkpoint_match.group(1) checkpoint_id = checkpoint_match.group(1)
@@ -115,17 +51,108 @@ class ComfyMetadataParser(RecipeMetadataParser):
'version': '', 'version': '',
'type': 'checkpoint' 'type': 'checkpoint'
} }
# Get additional checkpoint info from Civitai
if metadata_provider: if metadata_provider:
try: try:
civitai_info_tuple = await metadata_provider.get_model_version_info(checkpoint_version_id) civitai_info_tuple = await metadata_provider.get_model_version_info(checkpoint_version_id)
civitai_info, _ = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None) civitai_info, _ = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
# Populate checkpoint with Civitai info
checkpoint = await self.populate_checkpoint_from_civitai(checkpoint, civitai_info) checkpoint = await self.populate_checkpoint_from_civitai(checkpoint, civitai_info)
except Exception as e: except Exception as e:
logger.error(f"Error fetching Civitai info for checkpoint: {e}") logger.error(f"Error fetching Civitai info for checkpoint: {e}")
recipe_base_model = checkpoint.get('baseModel') if checkpoint else None
loras = []
lora_candidates = []
for node in data.values():
if not isinstance(node, dict):
continue
inputs = node.get('inputs')
if not isinstance(inputs, dict):
continue
if node.get('class_type') == 'LoraLoader':
lora_name = inputs.get('lora_name', '')
if isinstance(lora_name, str) and lora_name:
lora_candidates.append((lora_name, inputs.get('strength_model', 1.0)))
continue
if node.get('class_type') != 'LoraLoaderLM':
continue
loras_data = inputs.get('loras', [])
if isinstance(loras_data, dict):
loras_data = loras_data.get('__value__', [])
if isinstance(loras_data, list) and len(loras_data) == 1 and isinstance(loras_data[0], list):
loras_data = loras_data[0]
if not isinstance(loras_data, list):
continue
for lora in loras_data:
if not isinstance(lora, dict) or not lora.get('active', False) or lora.get('_isDummy', False):
continue
lora_name = lora.get('name', '')
if isinstance(lora_name, str) and lora_name:
lora_candidates.append((lora_name, lora.get('strength', 1.0)))
for lora_name, weight in lora_candidates:
if isinstance(weight, str):
try:
weight = float(weight)
except ValueError:
weight = 1.0
lora_id_match = re.search(r'civitai:(\d+)@(\d+)', lora_name)
if lora_id_match:
model_id = lora_id_match.group(1)
model_version_id = lora_id_match.group(2)
entry_name = f"Lora {model_id}"
else:
model_id = 0
model_version_id = 0
entry_name = re.split(r'[\\/]', lora_name)[-1]
entry_name = re.sub(r'\.[^.]+$', '', entry_name)
lora_entry = {
'id': model_version_id,
'modelId': model_id,
'name': entry_name,
'version': '',
'type': 'lora',
'weight': weight,
'existsLocally': False,
'localPath': None,
'file_name': entry_name,
'hash': '',
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
if lora_id_match:
if metadata_provider:
try:
civitai_info_tuple = await metadata_provider.get_model_version_info(model_version_id)
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info_tuple,
recipe_scanner
)
if populated_entry is None:
continue
lora_entry = populated_entry
except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA: {e}")
else:
if not recipe_scanner:
continue
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
if not local_lora:
continue
lora_entry = self.populate_lora_from_local(lora_entry, local_lora)
loras.append(lora_entry)
# Extract generation parameters # Extract generation parameters
gen_params = {} gen_params = {}
+1 -1
View File
@@ -30,7 +30,7 @@ class MetaFormatParser(RecipeMetadataParser):
prompt = parts[0].strip() prompt = parts[0].strip()
# Initialize metadata # Initialize metadata
metadata = {"prompt": prompt, "loras": []} metadata: Dict[str, Any] = {"prompt": prompt, "loras": []}
# Extract negative prompt and parameters if available # Extract negative prompt and parameters if available
if len(parts) > 1: if len(parts) > 1:
+10 -2
View File
@@ -91,7 +91,15 @@ class RecipeFormatParser(RecipeMetadataParser):
exists_locally = lora_scanner.has_hash(lora['hash']) exists_locally = lora_scanner.has_hash(lora['hash'])
if exists_locally: if exists_locally:
lora_cache = await lora_scanner.get_cached_data() lora_cache = await lora_scanner.get_cached_data()
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None) # Cascade match: full sha256, stored autov3, or autov2 (sha256[:10]).
h = (lora.get('hash') or '').lower()
lora_item = next(
(item for item in lora_cache.raw_data
if (item.get("sha256") or "").lower() == h
or (item.get("autov3") or "").lower() == h
or (item.get("sha256") or "")[:10].lower() == h),
None
)
if lora_item: if lora_item:
lora_entry['existsLocally'] = True lora_entry['existsLocally'] = True
lora_entry['inLibrary'] = True lora_entry['inLibrary'] = True
@@ -148,7 +156,7 @@ class RecipeFormatParser(RecipeMetadataParser):
checkpoint_data = recipe_metadata.get('checkpoint') or {} checkpoint_data = recipe_metadata.get('checkpoint') or {}
if isinstance(checkpoint_data, dict) and checkpoint_data: if isinstance(checkpoint_data, dict) and checkpoint_data:
version_id = checkpoint_data.get('modelVersionId') or checkpoint_data.get('id') version_id = checkpoint_data.get('modelVersionId') or checkpoint_data.get('id')
checkpoint_entry = { checkpoint_entry: Dict[str, Any] = {
'id': version_id or 0, 'id': version_id or 0,
'modelId': checkpoint_data.get('modelId', 0), 'modelId': checkpoint_data.get('modelId', 0),
'name': checkpoint_data.get('name', 'Unknown Checkpoint'), 'name': checkpoint_data.get('name', 'Unknown Checkpoint'),
+9 -8
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Callable, Dict, Mapping from typing import TYPE_CHECKING, Awaitable, Callable, Dict, Mapping
import jinja2 import jinja2
from aiohttp import web from aiohttp import web
@@ -30,6 +30,7 @@ from ..services.websocket_progress_callback import (
WebSocketProgressCallback, WebSocketProgressCallback,
) )
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
from .model_route_registrar import COMMON_ROUTE_DEFINITIONS, ModelRouteRegistrar from .model_route_registrar import COMMON_ROUTE_DEFINITIONS, ModelRouteRegistrar
from .handlers.model_handlers import ( from .handlers.model_handlers import (
@@ -84,7 +85,7 @@ class BaseModelRoutes(ABC):
self.metadata_progress_callback = WebSocketBroadcastCallback() self.metadata_progress_callback = WebSocketBroadcastCallback()
self._handler_set: ModelHandlerSet | None = None self._handler_set: ModelHandlerSet | None = None
self._handler_mapping: Dict[str, Callable[[web.Request], web.StreamResponse]] | None = None self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None
self._preview_service = PreviewAssetService( self._preview_service = PreviewAssetService(
metadata_manager=MetadataManager, metadata_manager=MetadataManager,
@@ -131,7 +132,7 @@ class BaseModelRoutes(ABC):
self._handler_set = None self._handler_set = None
self._handler_mapping = None self._handler_mapping = None
def _ensure_handler_mapping(self) -> Mapping[str, Callable[[web.Request], web.StreamResponse]]: def _ensure_handler_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
if self._handler_mapping is None: if self._handler_mapping is None:
handler_set = self._create_handler_set() handler_set = self._create_handler_set()
self._handler_set = handler_set self._handler_set = handler_set
@@ -220,7 +221,7 @@ class BaseModelRoutes(ABC):
) )
@property @property
def route_handlers(self) -> Mapping[str, Callable[[web.Request], web.StreamResponse]]: def route_handlers(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
return self._ensure_handler_mapping() return self._ensure_handler_mapping()
def setup_routes(self, app: web.Application, prefix: str) -> None: def setup_routes(self, app: web.Application, prefix: str) -> None:
@@ -237,7 +238,7 @@ class BaseModelRoutes(ABC):
"""Setup model-specific routes.""" """Setup model-specific routes."""
raise NotImplementedError raise NotImplementedError
def _parse_specific_params(self, request: web.Request) -> Dict: def _parse_specific_params(self, request: web.Request) -> Dict[str, Any]:
"""Parse model-specific parameters - to be overridden by subclasses.""" """Parse model-specific parameters - to be overridden by subclasses."""
return {} return {}
@@ -251,9 +252,9 @@ class BaseModelRoutes(ABC):
def _find_model_file(self, files): def _find_model_file(self, files):
"""Find the appropriate model file from the files list - can be overridden by subclasses.""" """Find the appropriate model file from the files list - can be overridden by subclasses."""
return next((file for file in files if file.get("type") in ("Model", "Diffusion Model") and file.get("primary") is True), None) return next((file for file in files if file.get("type") in MODEL_WEIGHT_FILE_TYPES and file.get("primary") is True), None)
def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]: def get_handler(self, name: str) -> Callable[[web.Request], Awaitable[web.StreamResponse]]:
"""Expose handlers for subclasses or tests.""" """Expose handlers for subclasses or tests."""
return self._ensure_handler_mapping()[name] return self._ensure_handler_mapping()[name]
@@ -285,7 +286,7 @@ class BaseModelRoutes(ABC):
) )
return self.model_lifecycle_service return self.model_lifecycle_service
def _make_handler_proxy(self, name: str) -> Callable[[web.Request], web.StreamResponse]: def _make_handler_proxy(self, name: str) -> Callable[[web.Request], Awaitable[web.StreamResponse]]:
async def proxy(request: web.Request) -> web.StreamResponse: async def proxy(request: web.Request) -> web.StreamResponse:
try: try:
handler = self.get_handler(name) handler = self.get_handler(name)
+13 -9
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging import logging
import os import os
from typing import Callable, Mapping from typing import Awaitable, Callable, Mapping
import jinja2 import jinja2
from aiohttp import web from aiohttp import web
@@ -61,7 +61,9 @@ class BaseRecipeRoutes:
self._i18n_registered = False self._i18n_registered = False
self._startup_hooks_registered = False self._startup_hooks_registered = False
self._handler_set: RecipeHandlerSet | None = None self._handler_set: RecipeHandlerSet | None = None
self._handler_mapping: dict[str, Callable] | None = None self._handler_mapping: Mapping[
str, Callable[[web.Request], Awaitable[web.StreamResponse]]
] | None = None
async def attach_dependencies(self, app: web.Application | None = None) -> None: async def attach_dependencies(self, app: web.Application | None = None) -> None:
"""Resolve shared services from the registry.""" """Resolve shared services from the registry."""
@@ -84,7 +86,9 @@ class BaseRecipeRoutes:
app.on_startup.append(self.attach_dependencies) app.on_startup.append(self.attach_dependencies)
self._startup_hooks_registered = True self._startup_hooks_registered = True
def to_route_mapping(self) -> Mapping[str, Callable]: def to_route_mapping(
self,
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
"""Return a mapping of handler name to coroutine for registrar binding.""" """Return a mapping of handler name to coroutine for registrar binding."""
if self._handler_mapping is None: if self._handler_mapping is None:
@@ -124,17 +128,17 @@ class BaseRecipeRoutes:
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
) )
if not standalone_mode: if not standalone_mode:
from ..metadata_collector import get_metadata # type: ignore[import-not-found] from ..metadata_collector import get_metadata # pyright: ignore[reportMissingImports]
from ..metadata_collector.metadata_processor import ( # type: ignore[import-not-found] from ..metadata_collector.metadata_processor import ( # pyright: ignore[reportMissingImports]
MetadataProcessor, MetadataProcessor,
) )
from ..metadata_collector.metadata_registry import ( # type: ignore[import-not-found] from ..metadata_collector.metadata_registry import ( # pyright: ignore[reportMissingImports]
MetadataRegistry, MetadataRegistry,
) )
else: # pragma: no cover - optional dependency path else: # pragma: no cover - optional dependency path
get_metadata = None # type: ignore[assignment] get_metadata = None # pyright: ignore[reportAssignmentType]
MetadataProcessor = None # type: ignore[assignment] MetadataProcessor = None # pyright: ignore[reportAssignmentType]
MetadataRegistry = None # type: ignore[assignment] MetadataRegistry = None # pyright: ignore[reportAssignmentType]
analysis_service = RecipeAnalysisService( analysis_service = RecipeAnalysisService(
exif_utils=ExifUtils, exif_utils=ExifUtils,
+48 -8
View File
@@ -1,5 +1,6 @@
import logging import logging
from typing import Dict, List, Set import os
from typing import Any, Dict, List, Set
from aiohttp import web from aiohttp import web
from .base_model_routes import BaseModelRoutes from .base_model_routes import BaseModelRoutes
@@ -7,6 +8,7 @@ from .model_route_registrar import ModelRouteRegistrar
from ..services.checkpoint_service import CheckpointService from ..services.checkpoint_service import CheckpointService
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..config import config from ..config import config
from ..utils.utils import _format_model_name_for_comfyui
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,13 +30,13 @@ class CheckpointRoutes(BaseModelRoutes):
# Attach service dependencies # Attach service dependencies
self.attach_service(self.service) self.attach_service(self.service)
def setup_routes(self, app: web.Application): def setup_routes(self, app: web.Application, prefix: str = "checkpoints"):
"""Setup Checkpoint routes""" """Setup Checkpoint routes"""
# Schedule service initialization on app startup # Schedule service initialization on app startup
app.on_startup.append(lambda _: self.initialize_services()) app.on_startup.append(lambda _: self.initialize_services())
# Setup common routes with 'checkpoints' prefix (includes page route) # Setup common routes with 'checkpoints' prefix (includes page route)
super().setup_routes(app, 'checkpoints') super().setup_routes(app, prefix)
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str): def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
"""Setup Checkpoint-specific routes""" """Setup Checkpoint-specific routes"""
@@ -45,6 +47,44 @@ class CheckpointRoutes(BaseModelRoutes):
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/checkpoints_roots', prefix, self.get_checkpoints_roots) registrar.add_prefixed_route('GET', '/api/lm/{prefix}/checkpoints_roots', prefix, self.get_checkpoints_roots)
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/unet_roots', prefix, self.get_unet_roots) registrar.add_prefixed_route('GET', '/api/lm/{prefix}/unet_roots', prefix, self.get_unet_roots)
# Name/base_model pool for the Random Checkpoint/Unet Loader nodes
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/loader-pool', prefix, self.get_loader_pool)
async def get_loader_pool(self, request: web.Request) -> web.Response:
"""Return ComfyUI-formatted model names with their base_model.
Backing data for the Random Checkpoint/Unet Loader nodes: the front-end
filters the ckpt_name/unet_name combo options by base_model using this
pool, so control_after_generate randomizes within the narrowed set.
"""
try:
sub_type = request.query.get("sub_type", "checkpoint")
if sub_type not in ("checkpoint", "diffusion_model"):
return web.json_response({"error": "invalid sub_type"}, status=400)
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
model_roots = scanner.get_model_roots()
items: List[Dict[str, str]] = []
for item in cache.raw_data:
if item.get("sub_type") != sub_type:
continue
file_path = item.get("file_path", "")
if not file_path or not os.path.exists(file_path):
continue
formatted_name = _format_model_name_for_comfyui(file_path, model_roots)
if formatted_name:
items.append(
{
"name": formatted_name,
"base_model": item.get("base_model", "") or "",
}
)
items.sort(key=lambda x: x["name"])
return web.json_response({"items": items})
except Exception as e:
logger.error(f"Error getting loader pool: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
def _validate_civitai_model_type(self, model_type: str) -> bool: def _validate_civitai_model_type(self, model_type: str) -> bool:
"""Validate CivitAI model type for Checkpoint""" """Validate CivitAI model type for Checkpoint"""
return model_type.lower() == 'checkpoint' return model_type.lower() == 'checkpoint'
@@ -53,9 +93,9 @@ class CheckpointRoutes(BaseModelRoutes):
"""Get expected model types string for error messages""" """Get expected model types string for error messages"""
return "Checkpoint" return "Checkpoint"
def _parse_specific_params(self, request: web.Request) -> Dict: def _parse_specific_params(self, request: web.Request) -> Dict[str, Any]:
"""Parse Checkpoint-specific parameters""" """Parse Checkpoint-specific parameters"""
params: Dict = {} params: Dict[str, Any] = {}
if 'checkpoint_hash' in request.query: if 'checkpoint_hash' in request.query:
params['hash_filters'] = {'single_hash': request.query['checkpoint_hash'].lower()} params['hash_filters'] = {'single_hash': request.query['checkpoint_hash'].lower()}
@@ -70,7 +110,7 @@ class CheckpointRoutes(BaseModelRoutes):
"""Get detailed information for a specific checkpoint by name""" """Get detailed information for a specific checkpoint by name"""
try: try:
name = request.match_info.get('name', '') name = request.match_info.get('name', '')
checkpoint_info = await self.service.get_model_info_by_name(name) checkpoint_info = await self.service.get_model_info_by_name(name) # pyright: ignore[reportAttributeAccessIssue]
if checkpoint_info: if checkpoint_info:
return web.json_response(checkpoint_info) return web.json_response(checkpoint_info)
@@ -89,7 +129,7 @@ class CheckpointRoutes(BaseModelRoutes):
roots.extend(config.checkpoints_roots or []) roots.extend(config.checkpoints_roots or [])
roots.extend(config.extra_checkpoints_roots or []) roots.extend(config.extra_checkpoints_roots or [])
# Remove duplicates while preserving order # Remove duplicates while preserving order
seen: set = set() seen: set[str] = set()
unique_roots: List[str] = [] unique_roots: List[str] = []
for root in roots: for root in roots:
if root and root not in seen: if root and root not in seen:
@@ -114,7 +154,7 @@ class CheckpointRoutes(BaseModelRoutes):
roots.extend(config.unet_roots or []) roots.extend(config.unet_roots or [])
roots.extend(config.extra_unet_roots or []) roots.extend(config.extra_unet_roots or [])
# Remove duplicates while preserving order # Remove duplicates while preserving order
seen: set = set() seen: set[str] = set()
unique_roots: List[str] = [] unique_roots: List[str] = []
for root in roots: for root in roots:
if root and root not in seen: if root and root not in seen:
+3 -3
View File
@@ -26,13 +26,13 @@ class EmbeddingRoutes(BaseModelRoutes):
# Attach service dependencies # Attach service dependencies
self.attach_service(self.service) self.attach_service(self.service)
def setup_routes(self, app: web.Application): def setup_routes(self, app: web.Application, prefix: str = "embeddings"):
"""Setup Embedding routes""" """Setup Embedding routes"""
# Schedule service initialization on app startup # Schedule service initialization on app startup
app.on_startup.append(lambda _: self.initialize_services()) app.on_startup.append(lambda _: self.initialize_services())
# Setup common routes with 'embeddings' prefix (includes page route) # Setup common routes with 'embeddings' prefix (includes page route)
super().setup_routes(app, 'embeddings') super().setup_routes(app, prefix)
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str): def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
"""Setup Embedding-specific routes""" """Setup Embedding-specific routes"""
@@ -51,7 +51,7 @@ class EmbeddingRoutes(BaseModelRoutes):
"""Get detailed information for a specific embedding by name""" """Get detailed information for a specific embedding by name"""
try: try:
name = request.match_info.get('name', '') name = request.match_info.get('name', '')
embedding_info = await self.service.get_model_info_by_name(name) embedding_info = await self.service.get_model_info_by_name(name) # pyright: ignore[reportAttributeAccessIssue]
if embedding_info: if embedding_info:
return web.json_response(embedding_info) return web.json_response(embedding_info)
+8 -4
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Callable, Mapping from typing import Any, Awaitable, Callable, Mapping
from aiohttp import web from aiohttp import web
@@ -35,7 +35,7 @@ class ExampleImagesRoutes:
*, *,
ws_manager, ws_manager,
download_manager: DownloadManager | None = None, download_manager: DownloadManager | None = None,
processor=ExampleImagesProcessor, processor: Any = ExampleImagesProcessor,
file_manager=ExampleImagesFileManager, file_manager=ExampleImagesFileManager,
cleanup_service: ExampleImagesCleanupService | None = None, cleanup_service: ExampleImagesCleanupService | None = None,
) -> None: ) -> None:
@@ -46,7 +46,9 @@ class ExampleImagesRoutes:
self._file_manager = file_manager self._file_manager = file_manager
self._cleanup_service = cleanup_service or ExampleImagesCleanupService() self._cleanup_service = cleanup_service or ExampleImagesCleanupService()
self._handler_set: ExampleImagesHandlerSet | None = None self._handler_set: ExampleImagesHandlerSet | None = None
self._handler_mapping: Mapping[str, Callable[[web.Request], web.StreamResponse]] | None = None self._handler_mapping: Mapping[
str, Callable[[web.Request], Awaitable[web.StreamResponse]]
] | None = None
@classmethod @classmethod
def setup_routes(cls, app: web.Application, *, ws_manager) -> None: def setup_routes(cls, app: web.Application, *, ws_manager) -> None:
@@ -61,7 +63,9 @@ class ExampleImagesRoutes:
registrar = ExampleImagesRouteRegistrar(app) registrar = ExampleImagesRouteRegistrar(app)
registrar.register_routes(self.to_route_mapping()) registrar.register_routes(self.to_route_mapping())
def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], web.StreamResponse]]: def to_route_mapping(
self,
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
"""Return the registrar-compatible mapping of handler names to callables.""" """Return the registrar-compatible mapping of handler names to callables."""
if self._handler_mapping is None: if self._handler_mapping is None:
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable, Mapping from typing import Awaitable, Callable, Mapping
from aiohttp import web from aiohttp import web
@@ -170,7 +170,7 @@ class ExampleImagesHandlerSet:
management: ExampleImagesManagementHandler management: ExampleImagesManagementHandler
files: ExampleImagesFileHandler files: ExampleImagesFileHandler
def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], web.StreamResponse]]: def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
"""Flatten handler methods into the registrar mapping.""" """Flatten handler methods into the registrar mapping."""
return { return {
+141 -50
View File
@@ -56,6 +56,7 @@ from ...utils.constants import (
) )
from .hf_handlers import HfHandler from .hf_handlers import HfHandler
from .agent_handlers import AgentHandler from .agent_handlers import AgentHandler
from .model_handlers import ModelCivitaiHandler
from ...utils.civitai_utils import rewrite_preview_url from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import ( from ...utils.example_images_paths import (
find_non_compliant_items_in_example_images_root, find_non_compliant_items_in_example_images_root,
@@ -276,7 +277,7 @@ def _collect_comfyui_session_logs(
) -> dict[str, Any]: ) -> dict[str, Any]:
if log_entries is None: if log_entries is None:
try: try:
import app.logger as comfy_logger import app.logger as comfy_logger # pyright: ignore[reportMissingImports]
log_entries = list(comfy_logger.get_logs() or []) log_entries = list(comfy_logger.get_logs() or [])
except Exception as exc: # pragma: no cover - environment dependent except Exception as exc: # pragma: no cover - environment dependent
@@ -422,10 +423,10 @@ class PromptServerProtocol(Protocol):
"""Subset of PromptServer used by the handlers.""" """Subset of PromptServer used by the handlers."""
instance: "PromptServerProtocol" instance: "PromptServerProtocol"
sockets: dict # maps clientId (sid) → WebSocketResponse sockets: dict[str, Any] # maps clientId (sid) → WebSocketResponse
def send_sync( def send_sync(
self, event: str, payload: dict | None = None, sid: str | None = None self, event: str, payload: dict[str, Any] | None = None, sid: str | None = None
) -> None: # pragma: no cover - protocol ) -> None: # pragma: no cover - protocol
... ...
@@ -443,7 +444,12 @@ class UsageStatsFactory(Protocol):
class MetadataProviderProtocol(Protocol): class MetadataProviderProtocol(Protocol):
async def get_model_versions( async def get_model_versions(
self, model_id: int self, model_id: int
) -> dict | None: # pragma: no cover - protocol ) -> dict[str, Any] | None: # pragma: no cover - protocol
...
async def get_user_models(
self, username: str, cursor: str | None = None
) -> Any: # pragma: no cover - protocol
... ...
@@ -466,16 +472,16 @@ class MetadataArchiveManagerProtocol(Protocol):
class BackupServiceProtocol(Protocol): class BackupServiceProtocol(Protocol):
async def create_snapshot( async def create_snapshot(
self, *, snapshot_type: str = "manual", persist: bool = False self, *, snapshot_type: str = "manual", persist: bool = False
) -> dict: # pragma: no cover - protocol ) -> dict[str, Any]: # pragma: no cover - protocol
... ...
async def restore_snapshot(self, archive_path: str) -> dict: # pragma: no cover - protocol async def restore_snapshot(self, archive_path: str) -> dict[str, Any]: # pragma: no cover - protocol
... ...
def get_status(self) -> dict: # pragma: no cover - protocol def get_status(self) -> dict[str, Any]: # pragma: no cover - protocol
... ...
def get_available_snapshots(self) -> list[dict]: # pragma: no cover - protocol def get_available_snapshots(self) -> list[dict[str, Any]]: # pragma: no cover - protocol
... ...
@@ -491,7 +497,7 @@ class NodeRegistry:
def __init__(self) -> None: def __init__(self) -> None:
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
# sid → {unique_id → node_info} # sid → {unique_id → node_info}
self._tab_nodes: Dict[str, Dict[str, dict]] = {} self._tab_nodes: Dict[str, Dict[str, dict[str, Any]]] = {}
self._ready = asyncio.Event() self._ready = asyncio.Event()
self._waiting_clients: set[str] = set() self._waiting_clients: set[str] = set()
@@ -504,7 +510,7 @@ class NodeRegistry:
# Helpers to build one node dict (extracted so it's reused for each tab) # Helpers to build one node dict (extracted so it's reused for each tab)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@staticmethod @staticmethod
def _build_node_dict(node: dict) -> dict: def _build_node_dict(node: dict[str, Any]) -> dict[str, Any]:
node_id = node["node_id"] node_id = node["node_id"]
graph_id = str(node["graph_id"]) graph_id = str(node["graph_id"])
unique_id = f"{graph_id}:{node_id}" unique_id = f"{graph_id}:{node_id}"
@@ -513,11 +519,11 @@ class NodeRegistry:
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
raw_capabilities = node.get("capabilities") raw_capabilities = node.get("capabilities")
capabilities: dict = {} capabilities: dict[str, Any] = {}
if isinstance(raw_capabilities, dict): if isinstance(raw_capabilities, dict):
capabilities = dict(raw_capabilities) capabilities = dict(raw_capabilities)
raw_widget_names: list | None = node.get("widget_names") raw_widget_names: list[Any] | None = node.get("widget_names")
if not isinstance(raw_widget_names, list): if not isinstance(raw_widget_names, list):
capability_widget_names = capabilities.get("widget_names") capability_widget_names = capabilities.get("widget_names")
raw_widget_names = ( raw_widget_names = (
@@ -565,9 +571,9 @@ class NodeRegistry:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public API # Public API
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def register_nodes(self, sid: str, nodes: list[dict]) -> None: async def register_nodes(self, sid: str, nodes: list[dict[str, Any]]) -> None:
"""Register/replace the node list for a single ComfyUI tab (identified by *sid*).""" """Register/replace the node list for a single ComfyUI tab (identified by *sid*)."""
tab_nodes: dict[str, dict] = {} tab_nodes: dict[str, dict[str, Any]] = {}
for node in nodes: for node in nodes:
nd = self._build_node_dict(node) nd = self._build_node_dict(node)
tab_nodes[nd["unique_id"]] = nd tab_nodes[nd["unique_id"]] = nd
@@ -602,7 +608,7 @@ class NodeRegistry:
except asyncio.TimeoutError: except asyncio.TimeoutError:
return False return False
async def get_merged_registry(self, active_sids: set[str] | None = None) -> dict: async def get_merged_registry(self, active_sids: set[str] | None = None) -> dict[str, Any]:
"""Return the union of all known tab nodes, pruning any tab that is no """Return the union of all known tab nodes, pruning any tab that is no
longer connected.""" longer connected."""
async with self._lock: async with self._lock:
@@ -619,8 +625,8 @@ class NodeRegistry:
len(stale_sids), stale_sids, len(stale_sids), stale_sids,
) )
merged: dict[str, dict] = {} merged: dict[str, dict[str, Any]] = {}
tab_info: dict[str, dict] = {} tab_info: dict[str, dict[str, Any]] = {}
for sid, nodes in self._tab_nodes.items(): for sid, nodes in self._tab_nodes.items():
tab_info[sid] = { tab_info[sid] = {
"node_count": len(nodes), "node_count": len(nodes),
@@ -653,7 +659,7 @@ class SupportersHandler:
def __init__(self, logger: logging.Logger | None = None) -> None: def __init__(self, logger: logging.Logger | None = None) -> None:
self._logger = logger or logging.getLogger(__name__) self._logger = logger or logging.getLogger(__name__)
def _load_supporters(self) -> dict: def _load_supporters(self) -> dict[str, Any]:
"""Load supporters data from JSON file.""" """Load supporters data from JSON file."""
try: try:
current_file = os.path.abspath(__file__) current_file = os.path.abspath(__file__)
@@ -1229,10 +1235,8 @@ class DoctorHandler:
settings_snapshot = _sanitize_sensitive_data( settings_snapshot = _sanitize_sensitive_data(
getattr(self._settings, "settings", {}) or {} getattr(self._settings, "settings", {}) or {}
) )
startup_messages_getter = getattr(self._settings, "get_startup_messages", None) startup_messages_getter: Any = getattr(self._settings, "get_startup_messages", None)
startup_messages = ( startup_messages = list(startup_messages_getter()) if startup_messages_getter else []
list(startup_messages_getter()) if callable(startup_messages_getter) else []
)
environment = { environment = {
"app_version": app_version, "app_version": app_version,
@@ -1439,7 +1443,7 @@ class SettingsHandler:
*, *,
settings_service=None, settings_service=None,
metadata_provider_updater: Callable[ metadata_provider_updater: Callable[
[], Awaitable[None] [], Awaitable[Any]
] = update_metadata_providers, ] = update_metadata_providers,
downloader_factory: Callable[ downloader_factory: Callable[
[], Awaitable[DownloaderProtocol] [], Awaitable[DownloaderProtocol]
@@ -1484,8 +1488,8 @@ class SettingsHandler:
settings_file = getattr(self._settings, "settings_file", None) settings_file = getattr(self._settings, "settings_file", None)
if settings_file: if settings_file:
response_data["settings_file"] = settings_file response_data["settings_file"] = settings_file
messages_getter = getattr(self._settings, "get_startup_messages", None) messages_getter: Any = getattr(self._settings, "get_startup_messages", None)
messages = list(messages_getter()) if callable(messages_getter) else [] messages = list(messages_getter()) if messages_getter else []
return web.json_response( return web.json_response(
{ {
"success": True, "success": True,
@@ -2005,11 +2009,11 @@ async def _noop_backup_service() -> None:
@dataclass @dataclass
class ServiceRegistryAdapter: class ServiceRegistryAdapter:
get_lora_scanner: Callable[[], Awaitable] get_lora_scanner: Callable[[], Awaitable[Any]]
get_checkpoint_scanner: Callable[[], Awaitable] get_checkpoint_scanner: Callable[[], Awaitable[Any]]
get_embedding_scanner: Callable[[], Awaitable] get_embedding_scanner: Callable[[], Awaitable[Any]]
get_downloaded_version_history_service: Callable[[], Awaitable] get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
get_backup_service: Callable[[], Awaitable] = _noop_backup_service get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
class ModelLibraryHandler: class ModelLibraryHandler:
@@ -2050,14 +2054,71 @@ class ModelLibraryHandler:
return await self._service_registry.get_downloaded_version_history_service() return await self._service_registry.get_downloaded_version_history_service()
@staticmethod @staticmethod
def _with_downloaded_flag(versions: list[dict]) -> list[dict]: def _with_downloaded_flag(versions: list[dict[str, Any]]) -> list[dict[str, Any]]:
enriched: list[dict] = [] enriched: list[dict[str, Any]] = []
for version in versions: for version in versions:
entry = dict(version) entry = dict(version)
entry.setdefault("hasBeenDownloaded", True) entry.setdefault("hasBeenDownloaded", True)
enriched.append(entry) enriched.append(entry)
return enriched return enriched
@staticmethod
async def _get_downloaded_files(
scanner: Any, model_version_id: int
) -> list[dict[str, Any]]:
"""Return per-file downloaded state for a version in the library.
This handler has no CivitAI version payload, so the remote file list
is taken from the local entries' cached ``civitai`` metadata (the
full version payload persisted at download time, see
``BaseModelMetadata.from_civitai_info``) and matched with the same
D2 rule used by ``get_civitai_versions`` (#1058). Local entries that
cannot be matched to a known remote file (e.g. missing metadata or
renamed files) are still reported with ``fileId`` set to None.
Returns ``[{fileId, fileName, filePath}]``.
"""
try:
cache = await scanner.get_cached_data()
except Exception: # pragma: no cover - defensive fallback
logger.debug(
"Failed to read cache for downloaded files of version %s",
model_version_id,
exc_info=True,
)
return []
files_getter = getattr(cache, "get_files_by_version_id", None)
local_entries = files_getter(model_version_id) if files_getter else []
if not local_entries:
return []
version_payload: Mapping[str, Any] = {}
for entry in local_entries:
civitai = entry.get("civitai") if isinstance(entry, Mapping) else None
if isinstance(civitai, Mapping) and isinstance(civitai.get("files"), list):
version_payload = civitai
break
downloaded = ModelCivitaiHandler._match_downloaded_files(
version_payload, local_entries
)
# Surface local files that D2 could not map to a known remote file
matched_paths = {item.get("filePath") for item in downloaded}
for entry in local_entries:
if not isinstance(entry, Mapping):
continue
if entry.get("file_path") in matched_paths:
continue
downloaded.append(
{
"fileId": None,
"fileName": entry.get("file_name"),
"filePath": entry.get("file_path"),
}
)
return downloaded
async def check_model_exists(self, request: web.Request) -> web.Response: async def check_model_exists(self, request: web.Request) -> web.Response:
try: try:
model_id_str = request.query.get("modelId") model_id_str = request.query.get("modelId")
@@ -2093,9 +2154,11 @@ class ModelLibraryHandler:
exists = False exists = False
model_type = None model_type = None
matched_scanner = None
if await lora_scanner.check_model_version_exists(model_version_id): if await lora_scanner.check_model_version_exists(model_version_id):
exists = True exists = True
model_type = "lora" model_type = "lora"
matched_scanner = lora_scanner
elif ( elif (
checkpoint_scanner checkpoint_scanner
and await checkpoint_scanner.check_model_version_exists( and await checkpoint_scanner.check_model_version_exists(
@@ -2104,6 +2167,7 @@ class ModelLibraryHandler:
): ):
exists = True exists = True
model_type = "checkpoint" model_type = "checkpoint"
matched_scanner = checkpoint_scanner
elif ( elif (
embedding_scanner embedding_scanner
and await embedding_scanner.check_model_version_exists( and await embedding_scanner.check_model_version_exists(
@@ -2112,6 +2176,7 @@ class ModelLibraryHandler:
): ):
exists = True exists = True
model_type = "embedding" model_type = "embedding"
matched_scanner = embedding_scanner
if exists: if exists:
return web.json_response( return web.json_response(
@@ -2120,6 +2185,9 @@ class ModelLibraryHandler:
"exists": True, "exists": True,
"modelType": model_type, "modelType": model_type,
"hasBeenDownloaded": False, "hasBeenDownloaded": False,
"downloadedFiles": await self._get_downloaded_files(
matched_scanner, model_version_id
),
} }
) )
@@ -2141,6 +2209,7 @@ class ModelLibraryHandler:
"exists": False, "exists": False,
"modelType": history_type, "modelType": history_type,
"hasBeenDownloaded": has_been_downloaded, "hasBeenDownloaded": has_been_downloaded,
"downloadedFiles": [],
} }
) )
@@ -2244,7 +2313,7 @@ class ModelLibraryHandler:
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner() checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner() embedding_scanner = await self._service_registry.get_embedding_scanner()
results: list[dict] = [] results: list[dict[str, Any]] = []
for model_id in model_ids: for model_id in model_ids:
lora_versions = await lora_scanner.get_model_versions_by_id(model_id) lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
if lora_versions: if lora_versions:
@@ -2353,7 +2422,7 @@ class ModelLibraryHandler:
) )
try: try:
model_version_id = int(data.get("modelVersionId")) model_version_id = int(data.get("modelVersionId")) # pyright: ignore[reportArgumentType]
except (TypeError, ValueError): except (TypeError, ValueError):
return web.json_response( return web.json_response(
{"success": False, "error": "Parameter modelVersionId must be an integer"}, {"success": False, "error": "Parameter modelVersionId must be an integer"},
@@ -2425,8 +2494,8 @@ class ModelLibraryHandler:
embedding_scanner = await self._service_registry.get_embedding_scanner() embedding_scanner = await self._service_registry.get_embedding_scanner()
found_type = None found_type = None
file_path = None
found_cache = None found_cache = None
entries: list = []
for model_type, scanner in ( for model_type, scanner in (
("lora", lora_scanner), ("lora", lora_scanner),
@@ -2437,27 +2506,43 @@ class ModelLibraryHandler:
if cache and model_version_id in cache.version_index: if cache and model_version_id in cache.version_index:
found_type = model_type found_type = model_type
found_cache = cache found_cache = cache
entry = cache.version_index[model_version_id] # A version can have several local files (#1058); collect
file_path = entry.get("file_path") # them all so the delete below covers every file.
files_getter = getattr(cache, "get_files_by_version_id", None)
if files_getter is not None:
entries = files_getter(model_version_id)
else:
entries = [cache.version_index[model_version_id]]
break break
if not file_path: file_paths = [
entry.get("file_path")
for entry in entries
if isinstance(entry, dict) and entry.get("file_path")
]
if not file_paths:
return web.json_response( return web.json_response(
{"success": False, "error": "Model version not found in any scanner cache"}, {"success": False, "error": "Model version not found in any scanner cache"},
status=404, status=404,
) )
for file_path in file_paths:
target_dir = os.path.dirname(file_path) target_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path) base_name = os.path.basename(file_path)
file_name, extension = os.path.splitext(base_name) file_name, extension = os.path.splitext(base_name)
await delete_model_artifacts(target_dir, file_name, main_extension=extension) await delete_model_artifacts(target_dir, file_name, main_extension=extension)
if found_cache: if found_cache:
removed_paths = set(file_paths)
found_cache.raw_data = [ found_cache.raw_data = [
item item
for item in found_cache.raw_data for item in found_cache.raw_data
if item.get("file_path") != file_path if item.get("file_path") not in removed_paths
] ]
rebuild = getattr(found_cache, "rebuild_version_index", None)
if rebuild is not None:
rebuild()
await found_cache.resort() await found_cache.resort()
scanner_map = { scanner_map = {
@@ -2465,10 +2550,11 @@ class ModelLibraryHandler:
"checkpoint": checkpoint_scanner, "checkpoint": checkpoint_scanner,
"embedding": embedding_scanner, "embedding": embedding_scanner,
} }
scanner = scanner_map.get(found_type) scanner = scanner_map.get(found_type or "")
if scanner: if scanner:
persist = getattr(scanner, "_persist_current_cache", None) scanner.bump_cache_version()
if callable(persist): persist: Any = getattr(scanner, "_persist_current_cache", None)
if persist:
await persist() await persist()
history_service = await self._get_download_history_service() history_service = await self._get_download_history_service()
@@ -2479,6 +2565,7 @@ class ModelLibraryHandler:
"success": True, "success": True,
"modelType": found_type, "modelType": found_type,
"modelVersionId": model_version_id, "modelVersionId": model_version_id,
"deletedFiles": len(file_paths),
} }
) )
except Exception as exc: except Exception as exc:
@@ -2649,13 +2736,13 @@ class ModelLibraryHandler:
} }
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES} lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES}
type_scanner_map: Dict[str, object | None] = { type_scanner_map: Dict[str, Any] = {
**{alias: lora_scanner for alias in lora_type_aliases}, **{alias: lora_scanner for alias in lora_type_aliases},
"checkpoint": checkpoint_scanner, "checkpoint": checkpoint_scanner,
"textualinversion": embedding_scanner, "textualinversion": embedding_scanner,
} }
versions: list[dict] = [] versions: list[dict[str, Any]] = []
history_service = await self._get_download_history_service() history_service = await self._get_download_history_service()
model_ids: list[int] = [] model_ids: list[int] = []
model_count = 0 model_count = 0
@@ -2707,6 +2794,8 @@ class ModelLibraryHandler:
tags_value = model.get("tags") tags_value = model.get("tags")
tags = tags_value if isinstance(tags_value, list) else [] tags = tags_value if isinstance(tags_value, list) else []
model_id = model.get("id") model_id = model.get("id")
if model_id is None:
continue
try: try:
model_id_int = int(model_id) model_id_int = int(model_id)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -2722,6 +2811,8 @@ class ModelLibraryHandler:
continue continue
version_id = version.get("id") version_id = version.get("id")
if version_id is None:
continue
try: try:
version_id_int = int(version_id) version_id_int = int(version_id)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -2783,7 +2874,7 @@ class MetadataArchiveHandler:
] = get_metadata_archive_manager, ] = get_metadata_archive_manager,
settings_service=None, settings_service=None,
metadata_provider_updater: Callable[ metadata_provider_updater: Callable[
[], Awaitable[None] [], Awaitable[Any]
] = update_metadata_providers, ] = update_metadata_providers,
) -> None: ) -> None:
self._metadata_archive_manager_factory = metadata_archive_manager_factory self._metadata_archive_manager_factory = metadata_archive_manager_factory
@@ -2930,7 +3021,7 @@ class BackupHandler:
if request.content_type.startswith("multipart/"): if request.content_type.startswith("multipart/"):
reader = await request.multipart() reader = await request.multipart()
field = await reader.next() field: Any = await reader.next()
uploaded = False uploaded = False
while field is not None: while field is not None:
if getattr(field, "filename", None): if getattr(field, "filename", None):
@@ -3549,7 +3640,7 @@ class NodeRegistryHandler:
except (TypeError, ValueError): except (TypeError, ValueError):
parsed_node_id = node_identifier parsed_node_id = node_identifier
payload: dict = { payload: dict[str, Any] = {
"id": parsed_node_id, "id": parsed_node_id,
"value": value, "value": value,
"mode": mode, "mode": mode,
@@ -3673,7 +3764,7 @@ class NodeRegistryHandler:
except (TypeError, ValueError): except (TypeError, ValueError):
parsed_node_id = node_identifier parsed_node_id = node_identifier
payload: dict = { payload: dict[str, Any] = {
"id": parsed_node_id, "id": parsed_node_id,
"value": value, "value": value,
"mode": mode, "mode": mode,
@@ -3740,8 +3831,8 @@ class MiscHandlerSet:
doctor: DoctorHandler, doctor: DoctorHandler,
example_workflows: ExampleWorkflowsHandler, example_workflows: ExampleWorkflowsHandler,
base_model: BaseModelHandlerSet, base_model: BaseModelHandlerSet,
hf_handler: HfHandler | None = None, hf_handler: Any = None,
agent_handler: AgentHandler | None = None, agent_handler: Any = None,
) -> None: ) -> None:
self.health = health self.health = health
self.settings = settings self.settings = settings
+231 -26
View File
@@ -51,6 +51,29 @@ LICENSE_FIELDS = (
) )
_broadcast_models_changed_tasks: set = set()
def _broadcast_models_changed() -> None:
"""Notify connected clients that the local model library changed.
The ComfyUI graph page listens for this event to invalidate its cached
model availability data (loras widget missing-model cues / error flags)
without waiting for the cache TTL to expire.
"""
try:
from ...services.websocket_manager import ws_manager
task = asyncio.create_task(ws_manager.broadcast({"type": "models_changed"}))
# Keep a reference so the task is not garbage-collected mid-await.
_broadcast_models_changed_tasks.add(task)
task.add_done_callback(_broadcast_models_changed_tasks.discard)
except Exception:
logging.getLogger(__name__).debug(
"Failed to broadcast models_changed", exc_info=True
)
class ModelPageView: class ModelPageView:
"""Render the HTML view for model listings.""" """Render the HTML view for model listings."""
@@ -71,7 +94,7 @@ class ModelPageView:
self._server_i18n = server_i18n self._server_i18n = server_i18n
self._logger = logger self._logger = logger
def _load_supporters(self) -> dict: def _load_supporters(self) -> dict[str, Any]:
"""Load supporters data from JSON file.""" """Load supporters data from JSON file."""
try: try:
current_file = os.path.abspath(__file__) current_file = os.path.abspath(__file__)
@@ -152,7 +175,7 @@ class ModelPageView:
self._template_env.filters["t"] = ( self._template_env.filters["t"] = (
self._server_i18n.create_template_filter() self._server_i18n.create_template_filter()
) )
self._template_env._i18n_filter_added = True # type: ignore[attr-defined] self._template_env._i18n_filter_added = True # pyright: ignore[reportAttributeAccessIssue]
from ...services.llm_service import PROVIDER_PRESETS from ...services.llm_service import PROVIDER_PRESETS
@@ -199,7 +222,7 @@ class ModelListingHandler:
self, self,
*, *,
service, service,
parse_specific_params: Callable[[web.Request], Dict], parse_specific_params: Callable[[web.Request], Dict[str, Any]],
logger: logging.Logger, logger: logging.Logger,
) -> None: ) -> None:
self._service = service self._service = service
@@ -287,7 +310,7 @@ class ModelListingHandler:
) )
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
def _parse_common_params(self, request: web.Request) -> Dict: def _parse_common_params(self, request: web.Request) -> Dict[str, Any]:
page = int(request.query.get("page", "1")) page = int(request.query.get("page", "1"))
page_size = min(int(request.query.get("page_size", "20")), 100) page_size = min(int(request.query.get("page_size", "20")), 100)
sort_by = request.query.get("sort_by", "name") sort_by = request.query.get("sort_by", "name")
@@ -460,6 +483,7 @@ class ModelManagementHandler:
return web.Response(text="Model path is required", status=400) return web.Response(text="Model path is required", status=400)
result = await self._lifecycle_service.delete_model(file_path) result = await self._lifecycle_service.delete_model(file_path)
_broadcast_models_changed()
return web.json_response(result) return web.json_response(result)
except ValueError as exc: except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400) return web.json_response({"success": False, "error": str(exc)}, status=400)
@@ -658,7 +682,7 @@ class ModelManagementHandler:
try: try:
reader = await request.multipart() reader = await request.multipart()
field = await reader.next() field: Any = await reader.next()
if field is None or field.name != "preview_file": if field is None or field.name != "preview_file":
raise ValueError("Expected 'preview_file' field") raise ValueError("Expected 'preview_file' field")
content_type = field.headers.get("Content-Type", "image/png") content_type = field.headers.get("Content-Type", "image/png")
@@ -700,7 +724,7 @@ class ModelManagementHandler:
{ {
"success": True, "success": True,
"preview_url": config.get_preview_static_url( "preview_url": config.get_preview_static_url(
result["preview_path"] str(result["preview_path"])
), ),
"preview_nsfw_level": result["preview_nsfw_level"], "preview_nsfw_level": result["preview_nsfw_level"],
} }
@@ -781,7 +805,7 @@ class ModelManagementHandler:
result = await self._preview_service.replace_preview( result = await self._preview_service.replace_preview(
model_path=model_path, model_path=model_path,
preview_data=preview_data, preview_data=preview_bytes,
content_type=content_type, content_type=content_type,
original_filename=original_filename, original_filename=original_filename,
nsfw_level=nsfw_level, nsfw_level=nsfw_level,
@@ -793,7 +817,7 @@ class ModelManagementHandler:
{ {
"success": True, "success": True,
"preview_url": config.get_preview_static_url( "preview_url": config.get_preview_static_url(
result["preview_path"] str(result["preview_path"])
), ),
"preview_nsfw_level": result["preview_nsfw_level"], "preview_nsfw_level": result["preview_nsfw_level"],
} }
@@ -931,6 +955,8 @@ class ModelManagementHandler:
file_path=file_path, new_file_name=new_file_name file_path=file_path, new_file_name=new_file_name
) )
_broadcast_models_changed()
return web.json_response( return web.json_response(
{ {
**result, **result,
@@ -959,6 +985,7 @@ class ModelManagementHandler:
) )
result = await self._lifecycle_service.bulk_delete_models(file_paths) result = await self._lifecycle_service.bulk_delete_models(file_paths)
_broadcast_models_changed()
return web.json_response(result) return web.json_response(result)
except ValueError as exc: except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400) return web.json_response({"success": False, "error": str(exc)}, status=400)
@@ -1061,6 +1088,7 @@ class ModelQueryHandler:
await self._service.scan_models( await self._service.scan_models(
force_refresh=True, rebuild_cache=full_rebuild force_refresh=True, rebuild_cache=full_rebuild
) )
_broadcast_models_changed()
if self._service.scanner.is_cancelled(): if self._service.scanner.is_cancelled():
return web.json_response( return web.json_response(
{ {
@@ -1488,8 +1516,73 @@ class ModelQueryHandler:
search = request.query.get("search", "").strip() search = request.query.get("search", "").strip()
limit = min(int(request.query.get("limit", "15")), 100) limit = min(int(request.query.get("limit", "15")), 100)
offset = max(0, int(request.query.get("offset", "0"))) offset = max(0, int(request.query.get("offset", "0")))
folder = request.query.get("folder")
recursive = request.query.get("recursive", "true").lower() == "true"
base_models = list(request.query.getall("base_model", []))
model_types = list(request.query.getall("model_type", []))
tag_filters: Dict[str, str] = {}
for tag in request.query.getall("tag_include", []):
if tag:
tag_filters[tag] = "include"
for tag in request.query.getall("tag_exclude", []):
if tag:
tag_filters[tag] = "exclude"
auto_tag_filters: Dict[str, str] = {}
for tag in request.query.getall("auto_tag_include", []):
if tag:
auto_tag_filters[tag] = "include"
for tag in request.query.getall("auto_tag_exclude", []):
if tag:
auto_tag_filters[tag] = "exclude"
tag_logic = request.query.get("tag_logic", "any").lower()
if tag_logic not in ("any", "all"):
tag_logic = "any"
credit_required = request.query.get("credit_required")
if credit_required is not None:
credit_required = credit_required.lower() not in ("false", "0", "")
allow_selling_generated_content = request.query.get(
"allow_selling_generated_content"
)
if allow_selling_generated_content is not None:
allow_selling_generated_content = (
allow_selling_generated_content.lower() not in ("false", "0", "")
)
# The presence of the recursive param (always sent by the loras
# widget when filter mode is on) signals that the filter pipeline
# must run even when no concrete filter is set, so global settings
# like show_only_sfw stay consistent with the list endpoint.
apply_filters = (
"recursive" in request.query
or folder is not None
or bool(base_models)
or bool(model_types)
or bool(tag_filters)
or bool(auto_tag_filters)
or credit_required is not None
or allow_selling_generated_content is not None
)
matching_paths = await self._service.search_relative_paths( matching_paths = await self._service.search_relative_paths(
search, limit, offset search,
limit,
offset,
folder=folder,
recursive=recursive,
base_models=base_models,
model_types=model_types,
tags=tag_filters,
auto_tags=auto_tag_filters,
tag_logic=tag_logic,
credit_required=credit_required,
allow_selling_generated_content=allow_selling_generated_content,
apply_filters=apply_filters,
) )
return web.json_response( return web.json_response(
{"success": True, "relative_paths": matching_paths} {"success": True, "relative_paths": matching_paths}
@@ -1566,7 +1659,8 @@ class ModelDownloadHandler:
import json import json
try: try:
data["file_params"] = json.loads(file_params_json) # Normalize falsy payloads (e.g. {}) to None (#1058)
data["file_params"] = json.loads(file_params_json) or None
except json.JSONDecodeError: except json.JSONDecodeError:
self._logger.warning( self._logger.warning(
"Invalid file_params JSON: %s", file_params_json "Invalid file_params JSON: %s", file_params_json
@@ -1718,7 +1812,8 @@ class ModelDownloadHandler:
model_id = int(model_id_str) if model_id_str else None model_id = int(model_id_str) if model_id_str else None
model_version_id = int(model_version_id_str) if model_version_id_str else None model_version_id = int(model_version_id_str) if model_version_id_str else None
file_params = json.loads(file_params_json) if file_params_json else None # Normalize falsy payloads (e.g. {}) to None (#1058)
file_params = (json.loads(file_params_json) if file_params_json else None) or None
service = await DownloadQueueService.get_instance() service = await DownloadQueueService.get_instance()
item = await service.add_to_queue( item = await service.add_to_queue(
@@ -1995,7 +2090,7 @@ class ModelCivitaiHandler:
settings_service: SettingsManager, settings_service: SettingsManager,
ws_manager: WebSocketManager, ws_manager: WebSocketManager,
logger: logging.Logger, logger: logging.Logger,
metadata_provider_factory: Callable[[], Awaitable], metadata_provider_factory: Callable[[], Awaitable[Any]],
validate_model_type: Callable[[str], bool], validate_model_type: Callable[[str], bool],
expected_model_types: Callable[[], str], expected_model_types: Callable[[], str],
find_model_file: Callable[ find_model_file: Callable[
@@ -2060,7 +2155,7 @@ class ModelCivitaiHandler:
downloaded_version_ids = set( downloaded_version_ids = set(
await history_service.get_downloaded_version_ids( await history_service.get_downloaded_version_ids(
self._service.model_type, self._service.model_type,
model_id, int(model_id),
) )
) )
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
@@ -2094,6 +2189,19 @@ class ModelCivitaiHandler:
else: else:
version.pop("localPath", None) version.pop("localPath", None)
# Per-file downloaded state so multi-file versions can show
# which individual files are already in the library (#1058)
local_entries: List[Any] = []
if version_id is not None and cache:
files_getter = getattr(cache, "get_files_by_version_id", None)
if files_getter is not None:
local_entries = files_getter(version_id)
elif cache_entry is not None:
local_entries = [cache_entry]
version["downloadedFiles"] = self._match_downloaded_files(
version, local_entries
)
model_file = ( model_file = (
self._find_model_file(version.get("files", [])) self._find_model_file(version.get("files", []))
if isinstance(version.get("files"), Iterable) if isinstance(version.get("files"), Iterable)
@@ -2108,6 +2216,64 @@ class ModelCivitaiHandler:
) )
return web.Response(status=500, text=str(exc)) return web.Response(status=500, text=str(exc))
@staticmethod
def _match_downloaded_files(
version: Mapping[str, Any], local_entries: List[Any]
) -> List[Dict[str, Any]]:
"""Map local library entries back to individual files of a version.
Matching follows rule D2 (#1058): SHA256 is authoritative when the
local entry carries one; otherwise fall back to extension-less file
name equality. Returns ``[{fileId, fileName, filePath}]``.
"""
files = version.get("files")
if not isinstance(files, list) or not local_entries:
return []
by_hash: Dict[str, Mapping[str, Any]] = {}
by_name: Dict[str, Mapping[str, Any]] = {}
for file_info in files:
if not isinstance(file_info, Mapping):
continue
sha = str(
(file_info.get("hashes") or {}).get("SHA256") or ""
).strip().lower()
if sha:
by_hash.setdefault(sha, file_info)
name = str(file_info.get("name") or "").strip()
if name:
by_name.setdefault(os.path.splitext(name)[0], file_info)
downloaded: List[Dict[str, Any]] = []
seen_keys: set = set()
for entry in local_entries:
if not isinstance(entry, Mapping):
continue
matched: Optional[Mapping[str, Any]] = None
local_hash = str(entry.get("sha256") or "").strip().lower()
if local_hash:
matched = by_hash.get(local_hash)
if matched is None:
local_name = str(entry.get("file_name") or "").strip()
if local_name:
matched = by_name.get(local_name)
if matched is None:
continue
file_id = matched.get("id")
dedupe_key = file_id if file_id is not None else matched.get("name")
if dedupe_key in seen_keys:
continue
seen_keys.add(dedupe_key)
downloaded.append(
{
"fileId": file_id,
"fileName": matched.get("name"),
"filePath": entry.get("file_path"),
}
)
return downloaded
async def get_civitai_model_by_version(self, request: web.Request) -> web.Response: async def get_civitai_model_by_version(self, request: web.Request) -> web.Response:
try: try:
model_version_id = request.match_info.get("modelVersionId") model_version_id = request.match_info.get("modelVersionId")
@@ -2170,6 +2336,8 @@ class ModelMoveHandler:
result = await self._move_service.move_model( result = await self._move_service.move_model(
file_path, target_path, use_default_paths=use_default_paths file_path, target_path, use_default_paths=use_default_paths
) )
if result.get("success"):
_broadcast_models_changed()
status = 200 if result.get("success") else 500 status = 200 if result.get("success") else 500
return web.json_response(result, status=status) return web.json_response(result, status=status)
except Exception as exc: except Exception as exc:
@@ -2189,6 +2357,8 @@ class ModelMoveHandler:
result = await self._move_service.move_models_bulk( result = await self._move_service.move_models_bulk(
file_paths, target_path, use_default_paths=use_default_paths file_paths, target_path, use_default_paths=use_default_paths
) )
if result.get("success"):
_broadcast_models_changed()
return web.json_response(result) return web.json_response(result)
except Exception as exc: except Exception as exc:
self._logger.error("Error moving models in bulk: %s", exc, exc_info=True) self._logger.error("Error moving models in bulk: %s", exc, exc_info=True)
@@ -2234,6 +2404,7 @@ class ModelAutoOrganizeHandler:
progress_callback=self._progress_callback, progress_callback=self._progress_callback,
exclusion_patterns=exclusion_patterns, exclusion_patterns=exclusion_patterns,
) )
_broadcast_models_changed()
return web.json_response(result.to_dict()) return web.json_response(result.to_dict())
except AutoOrganizeInProgressError: except AutoOrganizeInProgressError:
return web.json_response( return web.json_response(
@@ -2337,8 +2508,8 @@ class ModelUpdateHandler:
self._logger.error("Failed to fetch license info: %s", exc, exc_info=True) self._logger.error("Failed to fetch license info: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
updated: List[Dict[str, str]] = [] updated: List[Dict[str, Any]] = []
errors: List[Dict[str, str]] = [] errors: List[Dict[str, Any]] = []
for model_id in model_ids: for model_id in model_ids:
license_payload = license_map.get(model_id) license_payload = license_map.get(model_id)
if not license_payload: if not license_payload:
@@ -2351,6 +2522,7 @@ class ModelUpdateHandler:
model_section = civitai_section.get("model") model_section = civitai_section.get("model")
if not isinstance(model_section, Mapping): if not isinstance(model_section, Mapping):
model_section = {} model_section = {}
model_section = dict(model_section)
model_section.update(resolved_payload) model_section.update(resolved_payload)
civitai_section["model"] = model_section civitai_section["model"] = model_section
metadata_payload["civitai"] = civitai_section metadata_payload["civitai"] = civitai_section
@@ -2366,7 +2538,7 @@ class ModelUpdateHandler:
) )
errors.append({"filePath": metadata_path, "error": str(exc)}) errors.append({"filePath": metadata_path, "error": str(exc)})
response_payload = {"success": True, "updated": updated} response_payload: Dict[str, Any] = {"success": True, "updated": updated}
missing_model_ids = [mid for mid in model_ids if mid not in license_map] missing_model_ids = [mid for mid in model_ids if mid not in license_map]
if missing_model_ids: if missing_model_ids:
response_payload["missingModelIds"] = missing_model_ids response_payload["missingModelIds"] = missing_model_ids
@@ -2436,6 +2608,7 @@ class ModelUpdateHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
hide_early_access = False hide_early_access = False
hide_paid = False
if self._settings is not None: if self._settings is not None:
try: try:
hide_early_access = bool( hide_early_access = bool(
@@ -2443,12 +2616,17 @@ class ModelUpdateHandler:
) )
except Exception: except Exception:
pass pass
try:
hide_paid = bool(self._settings.get("hide_paid_updates", False))
except Exception:
pass
serialized_records = [] serialized_records = []
for record in records.values(): for record in records.values():
has_update_fn = getattr(record, "has_update", None) has_update_fn = getattr(record, "has_update", None)
if callable(has_update_fn) and has_update_fn( if callable(has_update_fn) and has_update_fn(
hide_early_access=hide_early_access hide_early_access=hide_early_access,
hide_paid=hide_paid,
): ):
serialized_records.append(self._serialize_record(record)) serialized_records.append(self._serialize_record(record))
@@ -2602,10 +2780,16 @@ class ModelUpdateHandler:
if not record or not record.versions: if not record or not record.versions:
return record return record
# Find versions that need enrichment # Find versions that need enrichment. Permanent paid versions are not
# early access (mirror _is_early_access_active) and never carry an end
# time, so skip them to avoid pointless per-version API calls.
versions_needing_update = [] versions_needing_update = []
for version in record.versions: for version in record.versions:
if version.is_early_access and not version.early_access_ends_at: if (
version.is_early_access
and not version.early_access_ends_at
and not getattr(version, "is_paid", False)
):
versions_needing_update.append(version) versions_needing_update.append(version)
if not versions_needing_update: if not versions_needing_update:
@@ -2715,6 +2899,7 @@ class ModelUpdateHandler:
civitai_payload = metadata_payload.get("civitai") civitai_payload = metadata_payload.get("civitai")
if not isinstance(civitai_payload, Mapping): if not isinstance(civitai_payload, Mapping):
civitai_payload = {} civitai_payload = {}
civitai_payload = dict(civitai_payload)
model_payload = civitai_payload.get("model") model_payload = civitai_payload.get("model")
if not isinstance(model_payload, Mapping): if not isinstance(model_payload, Mapping):
@@ -2759,7 +2944,7 @@ class ModelUpdateHandler:
return aggregated return aggregated
def _extract_target_model_ids(self, payload: Dict) -> Optional[List[int]]: def _extract_target_model_ids(self, payload: Dict[str, Any]) -> Optional[List[int]]:
if not isinstance(payload, Mapping): if not isinstance(payload, Mapping):
return None return None
@@ -2787,7 +2972,7 @@ class ModelUpdateHandler:
return {} return {}
to_dict = getattr(metadata, "to_dict", None) to_dict = getattr(metadata, "to_dict", None)
if callable(to_dict): if to_dict:
try: try:
return to_dict() return to_dict()
except Exception: except Exception:
@@ -2798,7 +2983,7 @@ class ModelUpdateHandler:
return {} return {}
async def _read_json(self, request: web.Request) -> Dict: async def _read_json(self, request: web.Request) -> Dict[str, Any]:
if not request.can_read_body: if not request.can_read_body:
return {} return {}
try: try:
@@ -2830,10 +3015,11 @@ class ModelUpdateHandler:
record, record,
*, *,
version_context: Optional[Dict[int, Dict[str, Any]]] = None, version_context: Optional[Dict[int, Dict[str, Any]]] = None,
) -> Dict: ) -> Dict[str, Any]:
context = version_context or {} context = version_context or {}
# Check user setting for hiding early access versions # Check user setting for hiding early access versions
hide_early_access = False hide_early_access = False
hide_paid = False
if self._settings is not None: if self._settings is not None:
try: try:
hide_early_access = bool( hide_early_access = bool(
@@ -2841,6 +3027,10 @@ class ModelUpdateHandler:
) )
except Exception: except Exception:
pass pass
try:
hide_paid = bool(self._settings.get("hide_paid_updates", False))
except Exception:
pass
return { return {
"modelType": record.model_type, "modelType": record.model_type,
"modelId": record.model_id, "modelId": record.model_id,
@@ -2849,7 +3039,10 @@ class ModelUpdateHandler:
"inLibraryVersionIds": record.in_library_version_ids, "inLibraryVersionIds": record.in_library_version_ids,
"lastCheckedAt": record.last_checked_at, "lastCheckedAt": record.last_checked_at,
"shouldIgnore": record.should_ignore_model, "shouldIgnore": record.should_ignore_model,
"hasUpdate": record.has_update(hide_early_access=hide_early_access), "hasUpdate": record.has_update(
hide_early_access=hide_early_access,
hide_paid=hide_paid,
),
"versions": [ "versions": [
self._serialize_version(version, context.get(version.version_id)) self._serialize_version(version, context.get(version.version_id))
for version in record.versions for version in record.versions
@@ -2859,7 +3052,7 @@ class ModelUpdateHandler:
@staticmethod @staticmethod
def _serialize_version( def _serialize_version(
version, context: Optional[Dict[str, Any]] version, context: Optional[Dict[str, Any]]
) -> Dict: ) -> Dict[str, Any]:
context = context or {} context = context or {}
preview_override = context.get("preview_override") preview_override = context.get("preview_override")
preview_url = ( preview_url = (
@@ -2868,8 +3061,11 @@ class ModelUpdateHandler:
# Determine if version is currently in early access # Determine if version is currently in early access
# Two-phase detection: use exact end time if available, otherwise fallback to basic flag # Two-phase detection: use exact end time if available, otherwise fallback to basic flag
# Mirror _is_early_access_active: permanent paid versions (no end time) are NOT early access
is_early_access = False is_early_access = False
if version.early_access_ends_at: if getattr(version, "is_paid", False) and not version.early_access_ends_at:
is_early_access = False
elif version.early_access_ends_at:
try: try:
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -2884,6 +3080,13 @@ class ModelUpdateHandler:
# Fallback to basic EA flag from bulk API # Fallback to basic EA flag from bulk API
is_early_access = True is_early_access = True
paid_access_payload = None
if getattr(version, "paid_access", None):
try:
paid_access_payload = json.loads(version.paid_access)
except (TypeError, ValueError):
paid_access_payload = None
return { return {
"versionId": version.version_id, "versionId": version.version_id,
"name": version.name, "name": version.name,
@@ -2897,6 +3100,8 @@ class ModelUpdateHandler:
"earlyAccessEndsAt": version.early_access_ends_at, "earlyAccessEndsAt": version.early_access_ends_at,
"isEarlyAccess": is_early_access, "isEarlyAccess": is_early_access,
"usageControl": version.usage_control, "usageControl": version.usage_control,
"isPaid": bool(getattr(version, "is_paid", False)),
"paidAccess": paid_access_payload,
"filePath": context.get("file_path"), "filePath": context.get("file_path"),
"fileName": context.get("file_name"), "fileName": context.get("file_name"),
} }
@@ -0,0 +1,323 @@
"""Handler for the pending-delete undo endpoint.
Restores a staged delete batch (models or recipes) via
``PendingDeleteService.undo`` and then repairs the affected library caches:
the model cache entry is restored from the manifest's ``model_snapshot``
(including the version index and hash index), tag counts are re-incremented,
and the recipe cache is re-populated via ``RecipeScanner.add_recipe``.
The per-type scanner is resolved from the manifest's ``model_type`` page value
through the SAME ServiceRegistry getters the model route registrars use
(lora/checkpoint/embedding) - never a hardcoded lora scanner.
"""
from __future__ import annotations
import inspect
import json
import logging
import os
import re
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, cast
from aiohttp import web
from ...services.pending_delete_service import get_pending_delete_service
from .model_handlers import _broadcast_models_changed
logger = logging.getLogger(__name__)
# Manifest ``model_type`` page values -> ServiceRegistry scanner getter names.
# The model route registrars resolve per-type scanners via these getters
# (lora_routes / checkpoint_routes / embedding_routes); undo must do the same
# so the CORRECT cache is restored for the deleted model's type.
_MODEL_TYPE_GETTER_NAMES: Dict[str, str] = {
"loras": "get_lora_scanner",
"checkpoints": "get_checkpoint_scanner",
"embeddings": "get_embedding_scanner",
}
# Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is
# joined into filesystem paths by ``_find_batch_dir``, so reject anything that
# does not match this exact shape (blocks path-traversal via batch_id).
_BATCH_ID_RE = re.compile(r"^[0-9a-f]{32}$")
class PendingDeleteHandler:
"""Handle undo requests for staged model/recipe deletions."""
def __init__(
self,
*,
service_factory: Callable[[], Awaitable[Any]] = get_pending_delete_service,
scanner_getter: Optional[Callable[[str], Awaitable[Any]]] = None,
recipe_scanner_getter: Optional[Callable[[], Awaitable[Any]]] = None,
) -> None:
self._service_factory: Callable[[], Awaitable[Any]] = service_factory
self._scanner_getter: Callable[[str], Awaitable[Any]] = (
scanner_getter or self._resolve_scanner
)
self._recipe_scanner_getter: Callable[[], Awaitable[Any]] = (
recipe_scanner_getter or self._resolve_recipe_scanner
)
@staticmethod
async def _resolve_scanner(model_type: str) -> Any:
"""Resolve the per-type scanner for a manifest ``model_type``.
The getter is looked up on the ServiceRegistry module namespace at call
time so tests (and the registry stubs) can patch it.
"""
from ...services import service_registry
getter_name = _MODEL_TYPE_GETTER_NAMES.get(model_type)
if getter_name is None:
raise ValueError(f"Unknown model type: {model_type}")
getter = getattr(service_registry.ServiceRegistry, getter_name, None)
if not callable(getter):
raise ValueError(f"No scanner getter for model type: {model_type}")
scanner = await cast(Callable[[], Awaitable[Any]], getter)()
if scanner is None:
raise ValueError(f"No scanner registered for model type: {model_type}")
return scanner
@staticmethod
async def _resolve_recipe_scanner() -> Any:
"""Resolve the recipe scanner via the ServiceRegistry module namespace."""
from ...services import service_registry
getter = getattr(service_registry.ServiceRegistry, "get_recipe_scanner", None)
if not callable(getter):
raise ValueError("Recipe scanner getter unavailable")
scanner = await cast(Callable[[], Awaitable[Any]], getter)()
if scanner is None:
raise ValueError("No recipe scanner registered")
return scanner
async def undo_delete(self, request: web.Request) -> web.Response:
"""Restore a staged batch and its library cache entry.
Body: ``{"batch_id": str}``. On success returns
``{"success": True, "restored": [<original paths>], "kind": kind}``.
Expired/unknown batches and occupied target paths -> 404.
"""
try:
data = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
if not isinstance(data, dict):
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
batch_id = data.get("batch_id")
if not batch_id or not isinstance(batch_id, str):
return web.json_response(
{"success": False, "error": "batch_id is required"}, status=400
)
if not _BATCH_ID_RE.fullmatch(batch_id):
# batch_id is joined into a path by _find_batch_dir - restrict to
# the exact staged-id shape so traversal payloads get 400.
return web.json_response(
{"success": False, "error": "Invalid batch_id"}, status=400
)
service = await self._service_factory()
try:
# Read the manifest BEFORE undo: undo() removes the batch dir.
manifest = await self._read_staged_manifest(service, batch_id)
result = await service.undo(batch_id)
except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
except Exception as exc:
logger.error("Unexpected error undoing batch %s: %s", batch_id, exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
kind = result.get("kind")
try:
if kind == "model":
if manifest is not None:
await self._restore_model_cache(manifest)
else:
# undo() raises when the manifest is missing, so this only
# happens defensively - files are restored regardless.
logger.warning(
"Manifest missing after undo of %s; skipping cache restore",
batch_id,
)
_broadcast_models_changed()
elif kind == "recipe":
# Recipe undo is client-refresh only: re-add to the scanner
# cache, no models_changed broadcast.
if manifest is not None:
await self._restore_recipe_cache(result, manifest)
else:
logger.warning(
"Manifest missing after undo of %s; skipping cache restore",
batch_id,
)
except Exception as exc:
# Files are already restored; only the cache restoration failed.
logger.error(
"Cache restoration failed after undo of %s: %s",
batch_id,
exc,
exc_info=True,
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
return web.json_response(
{
"success": True,
"restored": result.get("restored", []),
"kind": kind,
}
)
@staticmethod
async def _read_staged_manifest(
service: Any, batch_id: str
) -> Optional[Dict[str, Any]]:
"""Locate and read the batch manifest while it still exists on disk."""
batch_dir = await service._find_batch_dir(batch_id)
if not batch_dir:
return None
manifest_path = os.path.join(batch_dir, "manifest.json")
try:
with open(manifest_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError) as exc:
logger.debug("Failed to read manifest for batch %s: %s", batch_id, exc)
return None
return payload if isinstance(payload, dict) else None
async def _restore_model_cache(self, manifest: Dict[str, Any]) -> None:
"""Re-add every deleted model's cache entry from the manifest.
Each main-file entry carries the deleted model's ``snapshot`` (added at
stage time), so a merged bulk manifest holds ALL snapshots - undo must
restore every one, not just the top-level winner's. Old-format
manifests without entry snapshots fall back to the top-level
``model_snapshot`` (backward compat / single-delete path).
"""
model_type = manifest.get("model_type")
if not model_type or not isinstance(model_type, str):
raise ValueError(f"Manifest carries no model_type: {manifest.get('batch_id')}")
scanner = await self._scanner_getter(model_type)
# Collect one snapshot per distinct file_path from the entry snapshots.
snapshots: List[Dict[str, Any]] = []
seen: Set[str] = set()
for entry in manifest.get("entries") or []:
snapshot = entry.get("snapshot")
if not isinstance(snapshot, dict):
continue
file_path = snapshot.get("file_path")
if not file_path or not isinstance(file_path, str):
continue
if file_path in seen:
continue
seen.add(file_path)
snapshots.append(snapshot)
if not snapshots:
# Backward compat: pre-F3 manifests carry only the top-level
# model_snapshot (single-delete path, unchanged behavior).
top = manifest.get("model_snapshot")
if isinstance(top, dict) and top.get("file_path"):
snapshots = [top]
else:
logger.warning(
"Manifest %s has no restorable model snapshot; skipping cache restore",
manifest.get("batch_id"),
)
return
cache = await scanner.get_cached_data()
if cache is None:
logger.warning(
"Scanner cache unavailable for %s; skipping cache restore", model_type
)
return
for snapshot in snapshots:
file_path = str(snapshot["file_path"])
# A rescan between delete and undo may have re-added a stale entry
# for this path - drop it so exactly one (the snapshot) remains.
cache.raw_data = [
item for item in cache.raw_data if item.get("file_path") != file_path
]
# Restore tag counts (mirror of the bulk-delete decrement in
# _batch_update_cache_for_deleted_models: undo re-increments).
tags = snapshot.get("tags")
if isinstance(tags, list):
for tag in tags:
if not isinstance(tag, str) or not tag:
continue
scanner._tags_count[tag] = scanner._tags_count.get(tag, 0) + 1
cache.raw_data.append(dict(snapshot))
# Re-register the path in the hash index (add_entry guards a
# missing sha256 internally; still guard defensively here).
sha256 = snapshot.get("sha256") or ""
autov3 = snapshot.get("autov3")
hash_index = getattr(scanner, "_hash_index", None)
if hash_index is not None and sha256 and file_path:
hash_index.add_entry(sha256, file_path, autov3)
# Follow the bulk-delete cache-update pattern ONCE after all entries,
# including the explicit version-index rebuild so the version index
# does not go stale.
cache.rebuild_version_index()
await cache.resort()
scanner.bump_cache_version()
persist = getattr(scanner, "_persist_current_cache", None)
if callable(persist):
result = persist()
if inspect.isawaitable(result):
await result
async def _restore_recipe_cache(
self, result: Dict[str, Any], manifest: Dict[str, Any]
) -> None:
"""Re-add a restored recipe via ``RecipeScanner.add_recipe``.
The recipe JSON embeds the full recipe_data (incl. id/file_path);
``add_recipe`` only READS the ``_json_path_map`` so the forced frontend
refresh self-heals any transient path-map gap.
"""
restored = result.get("restored") or []
json_path = next(
(p for p in restored if isinstance(p, str) and p.endswith(".json")),
None,
)
if not json_path or not os.path.exists(json_path):
# Defensive fallback to the manifest's recipe_snapshot file_path.
snapshot = manifest.get("recipe_snapshot") or {}
fallback = snapshot.get("file_path")
if fallback and os.path.exists(fallback):
json_path = fallback
else:
logger.warning(
"Restored recipe JSON not found in %s; skipping cache restore",
restored,
)
return
try:
with open(json_path, "r", encoding="utf-8") as handle:
recipe_data = json.load(handle)
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Failed to load restored recipe JSON %s: %s", json_path, exc)
return
if not isinstance(recipe_data, dict):
return
recipe_scanner = await self._recipe_scanner_getter()
await recipe_scanner.add_recipe(recipe_data)
__all__ = ["PendingDeleteHandler"]
+312 -23
View File
@@ -10,7 +10,7 @@ import asyncio
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Tuple
from aiohttp import web from aiohttp import web
@@ -34,6 +34,7 @@ from ...utils.civitai_utils import (
) )
from ...utils.constants import NSFW_LEVELS from ...utils.constants import NSFW_LEVELS
from ...utils.exif_utils import ExifUtils from ...utils.exif_utils import ExifUtils
from ...utils.recipe_open_stats import RecipeOpenStats
from ...recipes.merger import GenParamsMerger from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher from ...recipes.enrichment import RecipeEnricher
from ...services.websocket_manager import ws_manager as default_ws_manager from ...services.websocket_manager import ws_manager as default_ws_manager
@@ -44,6 +45,22 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]]
RecipeScannerGetter = Callable[[], Any] RecipeScannerGetter = Callable[[], Any]
CivitaiClientGetter = Callable[[], Any] CivitaiClientGetter = Callable[[], Any]
# Cap concurrent preview-dimension reads across requests. With a cold LRU
# cache one page can touch up to page_size image files; 16 balances SSD and
# HDD throughput without starving the event loop.
_DIMS_READ_SEMAPHORE = asyncio.Semaphore(16)
async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
"""Read preview dimensions off the event loop under the concurrency cap.
PIL I/O runs in a worker thread so it never blocks the event loop, and the
semaphore bounds how many files are opened at once even when many list
requests land together.
"""
async with _DIMS_READ_SEMAPHORE:
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
@dataclass(frozen=True) @dataclass(frozen=True)
class RecipeHandlerSet: class RecipeHandlerSet:
@@ -82,6 +99,7 @@ class RecipeHandlerSet:
"download_shared_recipe": self.sharing.download_shared_recipe, "download_shared_recipe": self.sharing.download_shared_recipe,
"get_recipe_syntax": self.query.get_recipe_syntax, "get_recipe_syntax": self.query.get_recipe_syntax,
"update_recipe": self.management.update_recipe, "update_recipe": self.management.update_recipe,
"record_recipe_open": self.management.record_recipe_open,
"reconnect_lora": self.management.reconnect_lora, "reconnect_lora": self.management.reconnect_lora,
"find_duplicates": self.query.find_duplicates, "find_duplicates": self.query.find_duplicates,
"move_recipes_bulk": self.management.move_recipes_bulk, "move_recipes_bulk": self.management.move_recipes_bulk,
@@ -96,6 +114,11 @@ class RecipeHandlerSet:
"repair_recipe": self.management.repair_recipe, "repair_recipe": self.management.repair_recipe,
"repair_recipes_bulk": self.management.repair_recipes_bulk, "repair_recipes_bulk": self.management.repair_recipes_bulk,
"get_repair_progress": self.management.get_repair_progress, "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,
"rematch_recipes_bulk": self.management.rematch_recipes_bulk,
"get_rematch_progress": self.management.get_rematch_progress,
"start_batch_import": self.batch_import.start_batch_import, "start_batch_import": self.batch_import.start_batch_import,
"get_batch_import_progress": self.batch_import.get_batch_import_progress, "get_batch_import_progress": self.batch_import.get_batch_import_progress,
"cancel_batch_import": self.batch_import.cancel_batch_import, "cancel_batch_import": self.batch_import.cancel_batch_import,
@@ -246,7 +269,8 @@ class RecipeListingHandler:
recursive=recursive, recursive=recursive,
) )
for item in result.get("items", []): items = result.get("items", [])
for item in items:
file_path = item.get("file_path") file_path = item.get("file_path")
if file_path: if file_path:
item["file_url"] = self.format_recipe_file_url(file_path) item["file_url"] = self.format_recipe_file_url(file_path)
@@ -255,6 +279,26 @@ class RecipeListingHandler:
item.setdefault("loras", []) item.setdefault("loras", [])
item.setdefault("base_model", "") item.setdefault("base_model", "")
# Batch preview dimension reads with asyncio.gather. The previous
# loop awaited asyncio.to_thread once per item, so a page_size=100
# request submitted 100 sequential thread calls (50-300ms cold-page
# latency). gather runs them concurrently while the semaphore caps
# disk opens; dimensions stay omitted (not null) when a preview has
# no readable size (video, missing file).
to_read = [
(i, item.get("file_path"))
for i, item in enumerate(items)
if item.get("file_path")
]
if to_read:
dims_list = await asyncio.gather(
*(_read_preview_dims(path) for _, path in to_read)
)
for (idx, _), dims in zip(to_read, dims_list):
if dims:
item = items[idx]
item["width"], item["height"] = dims
return web.json_response(result) return web.json_response(result)
except Exception as exc: except Exception as exc:
self._logger.error("Error retrieving recipes: %s", exc, exc_info=True) self._logger.error("Error retrieving recipes: %s", exc, exc_info=True)
@@ -538,7 +582,12 @@ class RecipeQueryHandler:
if recipe_scanner is None: if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable") raise RuntimeError("Recipe scanner unavailable")
fingerprint_groups = await recipe_scanner.find_all_duplicate_recipes() include_prompt = (
request.query.get("include_prompt", "false").lower() in ("1", "true")
)
fingerprint_groups = await recipe_scanner.find_all_duplicate_recipes(
include_prompt=include_prompt
)
url_groups = await recipe_scanner.find_duplicate_recipes_by_source() url_groups = await recipe_scanner.find_duplicate_recipes_by_source()
response_data = [] response_data = []
@@ -571,6 +620,7 @@ class RecipeQueryHandler:
response_data.append( response_data.append(
{ {
"type": "fingerprint", "type": "fingerprint",
"key": f"g-{len(response_data) + 1}",
"fingerprint": fingerprint, "fingerprint": fingerprint,
"count": len(recipes), "count": len(recipes),
"recipes": recipes, "recipes": recipes,
@@ -606,6 +656,7 @@ class RecipeQueryHandler:
response_data.append( response_data.append(
{ {
"type": "source_path", "type": "source_path",
"key": f"g-{len(response_data) + 1}",
"fingerprint": url, "fingerprint": url,
"count": len(recipes), "count": len(recipes),
"recipes": recipes, "recipes": recipes,
@@ -850,6 +901,159 @@ class RecipeManagementHandler:
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True) self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) 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()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
# 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()
):
return web.json_response(
{"success": False, "error": "Recipe rematch already in progress"},
status=409,
)
recipe_scanner.reset_cancellation()
async def progress_callback(data):
await self._ws_manager.broadcast_recipe_rematch_progress(data)
# Run in background to avoid timeout
async def run_rematch():
try:
await recipe_scanner.rematch_all_recipes(
progress_callback=progress_callback
)
except Exception as e:
self._logger.error(
f"Error in recipe rematch task: {e}", exc_info=True
)
await self._ws_manager.broadcast_recipe_rematch_progress(
{"status": "error", "error": str(e)}
)
finally:
# Keep the final status for a while so the UI can see it
await asyncio.sleep(5)
self._ws_manager.cleanup_recipe_rematch_progress()
asyncio.create_task(run_rematch())
return web.json_response(
{"success": True, "message": "Recipe rematch started"}
)
except Exception as exc:
self._logger.error("Error starting recipe rematch: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def cancel_rematch(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 rematch: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def rematch_recipes_bulk(self, request: web.Request) -> web.Response:
"""Rematch deleted resources for multiple recipes by their IDs.
Accepts a JSON body with a "recipe_ids" array. The per-recipe loop is
delegated to the scanner's rematch_recipes_bulk; this handler only
parses the request and returns the scanner's summary.
"""
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,
)
# A bulk rematch must not queue behind a running global rematch's
# mutation lock.
if self._ws_manager.is_recipe_rematch_running():
return web.json_response(
{"success": False, "error": "Recipe rematch already in progress"},
status=409,
)
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,
)
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids)
return web.json_response(result)
except Exception as exc:
self._logger.error(
"Error performing bulk rematch: %s", exc, exc_info=True
)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
async def rematch_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,
)
# Reject per-recipe rematches while a global run is in progress so
# they do not queue behind the mutation lock.
if self._ws_manager.is_recipe_rematch_running():
return web.json_response(
{"success": False, "error": "Recipe rematch already in progress"},
status=409,
)
recipe_id = request.match_info["recipe_id"]
result = await recipe_scanner.rematch_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 rematching single recipe: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_rematch_progress(self, request: web.Request) -> web.Response:
try:
progress = self._ws_manager.get_recipe_rematch_progress()
if progress:
return web.json_response({"success": True, "progress": progress})
return web.json_response(
{"success": False, "message": "No rematch in progress"}, status=404
)
except Exception as exc:
self._logger.error("Error getting rematch progress: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def reimport_recipe(self, request: web.Request) -> web.Response: async def reimport_recipe(self, request: web.Request) -> web.Response:
"""Delete a recipe and re-import it from its source URL. """Delete a recipe and re-import it from its source URL.
@@ -1045,10 +1249,10 @@ class RecipeManagementHandler:
*, *,
image_url: str, image_url: str,
name: str, name: str,
lora_entries: list, lora_entries: list[Any],
checkpoint_entry: dict, checkpoint_entry: Dict[str, Any] | None,
gen_params_request: dict, gen_params_request: Dict[str, Any] | None,
tags: list, tags: list[Any],
base_model: str, base_model: str,
source_path: str, source_path: str,
) -> web.Response: ) -> web.Response:
@@ -1081,6 +1285,12 @@ class RecipeManagementHandler:
_original_image_url, _original_image_url,
) = await self._download_remote_media(image_url) ) = await self._download_remote_media(image_url)
# Build a version-cached map of local model hashes to cache items so
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
# exist on disk. Built once and shared by every parse pass below.
local_cache = await recipe_scanner.build_local_hash_cache()
from ...recipes.parsers.civitai_image import CivitaiApiMetadataParser
# Extract embedded EXIF metadata (offloaded to thread pool in this call) # Extract embedded EXIF metadata (offloaded to thread pool in this call)
embedded_gen_params = {} embedded_gen_params = {}
parsed_embedded = None parsed_embedded = None
@@ -1102,6 +1312,13 @@ class RecipeManagementHandler:
) )
) )
if parser: if parser:
if isinstance(parser, CivitaiApiMetadataParser):
parsed_embedded = await parser.parse_metadata(
raw_embedded,
recipe_scanner=recipe_scanner,
local_cache=local_cache,
)
else:
parsed_embedded = await parser.parse_metadata( parsed_embedded = await parser.parse_metadata(
raw_embedded, recipe_scanner=recipe_scanner raw_embedded, recipe_scanner=recipe_scanner
) )
@@ -1135,6 +1352,13 @@ class RecipeManagementHandler:
civitai_inner_meta civitai_inner_meta
) )
if parser: if parser:
if isinstance(parser, CivitaiApiMetadataParser):
civitai_parsed = await parser.parse_metadata(
civitai_inner_meta,
recipe_scanner=recipe_scanner,
local_cache=local_cache,
)
else:
civitai_parsed = await parser.parse_metadata( civitai_parsed = await parser.parse_metadata(
civitai_inner_meta, recipe_scanner=recipe_scanner civitai_inner_meta, recipe_scanner=recipe_scanner
) )
@@ -1236,6 +1460,33 @@ class RecipeManagementHandler:
self._logger.error("Error updating recipe: %s", exc, exc_info=True) self._logger.error("Error updating recipe: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
async def record_recipe_open(self, request: web.Request) -> web.Response:
"""Record that a recipe's detail modal was opened.
Lightweight fire-and-forget endpoint backing the "Recently Opened"
sort. It only writes the timestamp into the separate open-stats file
recipe JSON and EXIF are never touched.
"""
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
recipe_id = request.match_info["recipe_id"]
# Skip recording opens for recipes the scanner no longer knows.
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipe_json_path:
return web.json_response(
{"success": False, "error": "Recipe not found"}, status=404
)
RecipeOpenStats().record_open(recipe_id)
return web.json_response({"success": True})
except Exception as exc:
self._logger.error("Error recording recipe open: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_recipe(self, request: web.Request) -> web.Response: async def move_recipe(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
@@ -1641,7 +1892,7 @@ class RecipeManagementHandler:
if not provider: if not provider:
return "" return ""
version_info = await provider.get_model_version_info(version_id) version_info = await provider.get_model_version_info(str(version_id))
if isinstance(version_info, tuple): if isinstance(version_info, tuple):
version_info = version_info[0] version_info = version_info[0]
@@ -1761,6 +2012,12 @@ class RecipeManagementHandler:
await self._download_remote_media(image_url) await self._download_remote_media(image_url)
) )
# Build a version-cached map of local model hashes to cache items so
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
# exist on disk. Built once and shared by every parse pass below.
local_cache = await recipe_scanner.build_local_hash_cache()
from ...recipes.parsers.civitai_image import CivitaiApiMetadataParser
# Extract embedded EXIF metadata # Extract embedded EXIF metadata
embedded_gen_params = {} embedded_gen_params = {}
parsed_embedded = None parsed_embedded = None
@@ -1782,6 +2039,13 @@ class RecipeManagementHandler:
) )
) )
if parser: if parser:
if isinstance(parser, CivitaiApiMetadataParser):
parsed_embedded = await parser.parse_metadata(
raw_embedded,
recipe_scanner=recipe_scanner,
local_cache=local_cache,
)
else:
parsed_embedded = await parser.parse_metadata( parsed_embedded = await parser.parse_metadata(
raw_embedded, recipe_scanner=recipe_scanner raw_embedded, recipe_scanner=recipe_scanner
) )
@@ -1822,6 +2086,13 @@ class RecipeManagementHandler:
) )
) )
if parser: if parser:
if isinstance(parser, CivitaiApiMetadataParser):
parsed_embedded = await parser.parse_metadata(
raw_orig,
recipe_scanner=recipe_scanner,
local_cache=local_cache,
)
else:
parsed_embedded = await parser.parse_metadata( parsed_embedded = await parser.parse_metadata(
raw_orig, recipe_scanner=recipe_scanner raw_orig, recipe_scanner=recipe_scanner
) )
@@ -1858,6 +2129,13 @@ class RecipeManagementHandler:
civitai_inner_meta civitai_inner_meta
) )
if parser: if parser:
if isinstance(parser, CivitaiApiMetadataParser):
civitai_parsed = await parser.parse_metadata(
civitai_inner_meta,
recipe_scanner=recipe_scanner,
local_cache=local_cache,
)
else:
civitai_parsed = await parser.parse_metadata( civitai_parsed = await parser.parse_metadata(
civitai_inner_meta, recipe_scanner=recipe_scanner civitai_inner_meta, recipe_scanner=recipe_scanner
) )
@@ -2072,30 +2350,41 @@ class RecipeManagementHandler:
parsed_input = {**image_data, **inner_meta} parsed_input = {**image_data, **inner_meta}
parsed_input.pop("meta", None) parsed_input.pop("meta", None)
# Build a local cache of {hash cache_item} so the parser can # Build the shared local hash cache so the parser can skip CivitAI
# skip CivitAI API calls for models that exist on disk. # API calls for models that exist on disk.
local_cache: Dict[str, Dict[str, Any]] = {} local_cache: Dict[str, Dict[str, Any]] = (
await recipe_scanner.build_local_hash_cache()
)
# Bounded supplement for un-backfilled parents. The shared builder
# never computes autov3; when the parent model exists on disk but
# its cached entry has no stored AutoV3, compute it for that single
# file and register the AutoV3 key so the parser can also match on
# that hash type (CivitAI metadata resources use AutoV3). This runs
# whenever the parent is found with an empty autov3, independent of
# whether the sha256 key is already present in the shared cache.
if model_hash:
lora_scanner = getattr(recipe_scanner, "_lora_scanner", None) lora_scanner = getattr(recipe_scanner, "_lora_scanner", None)
if lora_scanner and model_hash: if lora_scanner:
try: try:
parent_cache_data = await lora_scanner.get_cached_data() parent_cache_data = await lora_scanner.get_cached_data()
for item in getattr(parent_cache_data, "raw_data", []): for item in getattr(parent_cache_data, "raw_data", []):
if item.get("sha256", "").lower() == model_hash.lower(): if item.get("sha256", "").lower() == model_hash.lower():
local_cache[model_hash.lower()] = item autov3 = (item.get("autov3") or "").lower()
# Compute AutoV3 so the parser can also match on if not autov3:
# that hash type (CivitAI metadata resources use
# AutoV3).
file_path = item.get("file_path") file_path = item.get("file_path")
if file_path and os.path.exists(file_path): if file_path and os.path.exists(file_path):
try: try:
from ...utils.file_utils import ( from ...utils.file_utils import (
calculate_autov3, calculate_autov3,
) )
autov3 = calculate_autov3(file_path) autov3 = (
if autov3: calculate_autov3(file_path) or ""
local_cache[autov3.lower()] = item ).lower()
except Exception: except Exception:
pass pass
if autov3:
local_cache[autov3] = item
break break
except Exception: except Exception:
pass pass
@@ -2130,10 +2419,10 @@ class RecipeManagementHandler:
parent_model_id: int | None = None parent_model_id: int | None = None
parent_version_name: str | None = None parent_version_name: str | None = None
parent_model_name: str | None = None parent_model_name: str | None = None
# Prefer sha256 key; fall back to any cached entry. # Resolve the parent strictly by its sha256 key. There is no
# arbitrary fallback: with a full-library cache, picking any entry
# would corrupt the isDeleted reconciliation below.
parent_item = local_cache.get(model_hash.lower()) if model_hash else None parent_item = local_cache.get(model_hash.lower()) if model_hash else None
if parent_item is None and local_cache:
parent_item = next(iter(local_cache.values()))
if parent_item: if parent_item:
civ = parent_item.get("civitai") or {} civ = parent_item.get("civitai") or {}
if isinstance(civ, dict): if isinstance(civ, dict):
@@ -2349,7 +2638,7 @@ class RecipeAnalysisHandler:
content_type = request.headers.get("Content-Type", "") content_type = request.headers.get("Content-Type", "")
if "multipart/form-data" in content_type: if "multipart/form-data" in content_type:
reader = await request.multipart() reader = await request.multipart()
field = await reader.next() field: Any = await reader.next()
if field is None or field.name != "image": if field is None or field.name != "image":
raise RecipeValidationError("No image field found") raise RecipeValidationError("No image field found")
image_chunks = bytearray() image_chunks = bytearray()
+6 -71
View File
@@ -1,8 +1,8 @@
import asyncio import asyncio
import logging import logging
from aiohttp import web from aiohttp import web
from typing import Dict from typing import Any, Dict
from server import PromptServer # type: ignore from server import PromptServer # pyright: ignore[reportMissingImports]
from .base_model_routes import BaseModelRoutes from .base_model_routes import BaseModelRoutes
from .model_route_registrar import ModelRouteRegistrar from .model_route_registrar import ModelRouteRegistrar
@@ -31,13 +31,13 @@ class LoraRoutes(BaseModelRoutes):
# Attach service dependencies # Attach service dependencies
self.attach_service(self.service) self.attach_service(self.service)
def setup_routes(self, app: web.Application): def setup_routes(self, app: web.Application, prefix: str = "loras"):
"""Setup LoRA routes""" """Setup LoRA routes"""
# Schedule service initialization on app startup # Schedule service initialization on app startup
app.on_startup.append(lambda _: self.initialize_services()) app.on_startup.append(lambda _: self.initialize_services())
# Setup common routes with 'loras' prefix (includes page route) # Setup common routes with 'loras' prefix (includes page route)
super().setup_routes(app, "loras") super().setup_routes(app, prefix)
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str): def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
"""Setup LoRA-specific routes""" """Setup LoRA-specific routes"""
@@ -73,7 +73,7 @@ class LoraRoutes(BaseModelRoutes):
"POST", "/api/lm/{prefix}/get_trigger_words", prefix, self.get_trigger_words "POST", "/api/lm/{prefix}/get_trigger_words", prefix, self.get_trigger_words
) )
def _parse_specific_params(self, request: web.Request) -> Dict: def _parse_specific_params(self, request: web.Request) -> Dict[str, Any]:
"""Parse LoRA-specific parameters""" """Parse LoRA-specific parameters"""
params = {} params = {}
@@ -119,25 +119,6 @@ class LoraRoutes(BaseModelRoutes):
logger.error(f"Error getting letter counts: {e}") logger.error(f"Error getting letter counts: {e}")
return web.json_response({"success": False, "error": str(e)}, status=500) return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_lora_notes(self, request: web.Request) -> web.Response:
"""Get notes for a specific LoRA file"""
try:
lora_name = request.query.get("name")
if not lora_name:
return web.Response(text="Lora file name is required", status=400)
notes = await self.service.get_lora_notes(lora_name)
if notes is not None:
return web.json_response({"success": True, "notes": notes})
else:
return web.json_response(
{"success": False, "error": "LoRA not found in cache"}, status=404
)
except Exception as e:
logger.error(f"Error getting lora notes: {e}", exc_info=True)
return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_lora_trigger_words(self, request: web.Request) -> web.Response: async def get_lora_trigger_words(self, request: web.Request) -> web.Response:
"""Get trigger words for a specific LoRA file""" """Get trigger words for a specific LoRA file"""
try: try:
@@ -168,52 +149,6 @@ class LoraRoutes(BaseModelRoutes):
logger.error(f"Error getting lora usage tips by path: {e}", exc_info=True) logger.error(f"Error getting lora usage tips by path: {e}", exc_info=True)
return web.json_response({"success": False, "error": str(e)}, status=500) return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_lora_preview_url(self, request: web.Request) -> web.Response:
"""Get the static preview URL for a LoRA file"""
try:
lora_name = request.query.get("name")
if not lora_name:
return web.Response(text="Lora file name is required", status=400)
preview_url = await self.service.get_lora_preview_url(lora_name)
if preview_url:
return web.json_response({"success": True, "preview_url": preview_url})
else:
return web.json_response(
{
"success": False,
"error": "No preview URL found for the specified lora",
},
status=404,
)
except Exception as e:
logger.error(f"Error getting lora preview URL: {e}", exc_info=True)
return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_lora_civitai_url(self, request: web.Request) -> web.Response:
"""Get the Civitai URL for a LoRA file"""
try:
lora_name = request.query.get("name")
if not lora_name:
return web.Response(text="Lora file name is required", status=400)
result = await self.service.get_lora_civitai_url(lora_name)
if result["civitai_url"]:
return web.json_response({"success": True, **result})
else:
return web.json_response(
{
"success": False,
"error": "No Civitai data found for the specified lora",
},
status=404,
)
except Exception as e:
logger.error(f"Error getting lora Civitai URL: {e}", exc_info=True)
return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_random_loras(self, request: web.Request) -> web.Response: async def get_random_loras(self, request: web.Request) -> web.Response:
"""Get random LoRAs based on filters and strength ranges""" """Get random LoRAs based on filters and strength ranges"""
try: try:
@@ -337,7 +272,7 @@ class LoraRoutes(BaseModelRoutes):
graph_identifier = entry.get("graph_id") graph_identifier = entry.get("graph_id")
try: try:
parsed_node_id = int(node_identifier) parsed_node_id = int(node_identifier) # pyright: ignore[reportArgumentType]
except (TypeError, ValueError): except (TypeError, ValueError):
parsed_node_id = node_identifier parsed_node_id = node_identifier
+2 -2
View File
@@ -5,7 +5,7 @@ miscellaneous endpoints share a consistent registration flow.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable, Iterable, Mapping from typing import Any, Callable, Iterable, Mapping
from aiohttp import web from aiohttp import web
@@ -147,7 +147,7 @@ class MiscRouteRegistrar:
handler_lookup[definition.handler_name], handler_lookup[definition.handler_name],
) )
def _bind(self, method: str, path: str, handler: Callable) -> None: def _bind(self, method: str, path: str, handler: Callable[..., Any]) -> None:
add_method_name = self._METHOD_MAP[method.upper()] add_method_name = self._METHOD_MAP[method.upper()]
add_method = getattr(self._app.router, add_method_name) add_method = getattr(self._app.router, add_method_name)
add_method(path, handler) add_method(path, handler)
+1 -1
View File
@@ -7,7 +7,7 @@ import os
from typing import Awaitable, Callable, Mapping from typing import Awaitable, Callable, Mapping
from aiohttp import web from aiohttp import web
from server import PromptServer # type: ignore from server import PromptServer # pyright: ignore[reportMissingImports]
from ..services.metadata_service import ( from ..services.metadata_service import (
get_metadata_archive_manager, get_metadata_archive_manager,
+4 -4
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable, Iterable, Mapping from typing import Any, Callable, Iterable, Mapping
from aiohttp import web from aiohttp import web
@@ -174,15 +174,15 @@ class ModelRouteRegistrar:
handler_lookup[definition.handler_name], handler_lookup[definition.handler_name],
) )
def add_route(self, method: str, path: str, handler: Callable) -> None: def add_route(self, method: str, path: str, handler: Callable[..., Any]) -> None:
self._bind_route(method, path, handler) self._bind_route(method, path, handler)
def add_prefixed_route( def add_prefixed_route(
self, method: str, path_template: str, prefix: str, handler: Callable self, method: str, path_template: str, prefix: str, handler: Callable[..., Any]
) -> None: ) -> None:
self._bind_route(method, path_template.replace("{prefix}", prefix), handler) self._bind_route(method, path_template.replace("{prefix}", prefix), handler)
def _bind_route(self, method: str, path: str, handler: Callable) -> None: def _bind_route(self, method: str, path: str, handler: Callable[..., Any]) -> None:
add_method_name = self._METHOD_MAP[method.upper()] add_method_name = self._METHOD_MAP[method.upper()]
add_method = getattr(self._app.router, add_method_name) add_method = getattr(self._app.router, add_method_name)
add_method(path, handler) add_method(path, handler)
+25
View File
@@ -0,0 +1,25 @@
"""Route controller for the pending-delete undo endpoint."""
from __future__ import annotations
from aiohttp import web
from .handlers.pending_delete_handler import PendingDeleteHandler
class PendingDeleteRoutes:
"""Shared route controller mirroring MiscRoutes/UpdateRoutes.
Registered ONCE per mode (py/lora_manager.py, standalone.py); NEVER through
the per-model-type ModelRouteRegistrar, which is instantiated per model
type and would register this non-prefixed route three times.
"""
@staticmethod
def setup_routes(app: web.Application) -> None:
"""Register the shared undo-delete endpoint."""
handler = PendingDeleteHandler()
_ = app.router.add_post("/api/lm/undo-delete", handler.undo_delete)
__all__ = ["PendingDeleteRoutes"]
+10 -2
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable, Mapping from typing import Any, Callable, Mapping
from aiohttp import web from aiohttp import web
@@ -43,6 +43,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
), ),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"), RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/opened", "record_recipe_open"
),
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"), RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"), RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"), RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
@@ -61,6 +64,11 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"), RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"), RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"), 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"),
RouteDefinition("POST", "/api/lm/recipes/cancel-rematch", "cancel_rematch"),
RouteDefinition("GET", "/api/lm/recipes/rematch-progress", "get_rematch_progress"),
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"), RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
RouteDefinition( RouteDefinition(
"GET", "/api/lm/recipes/batch-import/progress", "get_batch_import_progress" "GET", "/api/lm/recipes/batch-import/progress", "get_batch_import_progress"
@@ -105,7 +113,7 @@ class RecipeRouteRegistrar:
handler = handler_lookup[definition.handler_name] handler = handler_lookup[definition.handler_name]
self._bind_route(definition.method, definition.path, handler) self._bind_route(definition.method, definition.path, handler)
def _bind_route(self, method: str, path: str, handler: Callable) -> None: def _bind_route(self, method: str, path: str, handler: Callable[..., Any]) -> None:
add_method_name = self._METHOD_MAP[method.upper()] add_method_name = self._METHOD_MAP[method.upper()]
add_method = getattr(self._app.router, add_method_name) add_method = getattr(self._app.router, add_method_name)
add_method(path, handler) add_method(path, handler)
+11 -10
View File
@@ -40,10 +40,11 @@ class StatsRoutes:
"""Route handlers for Statistics page and API endpoints""" """Route handlers for Statistics page and API endpoints"""
def __init__(self): def __init__(self):
self.lora_scanner = None self.lora_scanner: Any = None
self.checkpoint_scanner = None self.checkpoint_scanner: Any = None
self.embedding_scanner = None self.embedding_scanner: Any = None
self.usage_stats = None self.usage_stats: Any = None
self._i18n_filter_added = False
self.template_env = jinja2.Environment( self.template_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(config.templates_path), loader=jinja2.FileSystemLoader(config.templates_path),
autoescape=True autoescape=True
@@ -95,9 +96,9 @@ class StatsRoutes:
server_i18n.set_locale(user_language) server_i18n.set_locale(user_language)
# 为模板环境添加i18n过滤器 # 为模板环境添加i18n过滤器
if not hasattr(self.template_env, '_i18n_filter_added'): if not self._i18n_filter_added:
self.template_env.filters['t'] = server_i18n.create_template_filter() self.template_env.filters['t'] = server_i18n.create_template_filter()
self.template_env._i18n_filter_added = True self._i18n_filter_added = True
template = self.template_env.get_template('statistics.html') template = self.template_env.get_template('statistics.html')
rendered = template.render( rendered = template.render(
@@ -549,7 +550,7 @@ class StatsRoutes:
'error': str(e) 'error': str(e)
}, status=500) }, status=500)
def _count_unused_models(self, models: List[Dict], usage_data: Dict) -> int: def _count_unused_models(self, models: List[Dict[str, Any]], usage_data: Dict[str, Any]) -> int:
"""Count models that have never been used""" """Count models that have never been used"""
used_hashes = set(usage_data.keys()) used_hashes = set(usage_data.keys())
unused_count = 0 unused_count = 0
@@ -560,7 +561,7 @@ class StatsRoutes:
return unused_count return unused_count
def _get_top_used_models(self, usage_data: Dict, model_map: Dict, limit: int) -> List[Dict]: def _get_top_used_models(self, usage_data: Dict[str, Any], model_map: Dict[str, Any], limit: int) -> List[Dict[str, Any]]:
"""Get top used models with their metadata""" """Get top used models with their metadata"""
sorted_usage = sorted(usage_data.items(), key=lambda x: x[1].get('total', 0), reverse=True) sorted_usage = sorted(usage_data.items(), key=lambda x: x[1].get('total', 0), reverse=True)
@@ -578,7 +579,7 @@ class StatsRoutes:
return top_models return top_models
def _get_usage_timeline(self, usage_data: Dict, days: int) -> List[Dict]: def _get_usage_timeline(self, usage_data: Dict[str, Any], days: int) -> List[Dict[str, Any]]:
"""Get usage timeline for the past N days""" """Get usage timeline for the past N days"""
timeline = [] timeline = []
today = datetime.now() today = datetime.now()
@@ -614,7 +615,7 @@ class StatsRoutes:
return list(reversed(timeline)) # Oldest to newest return list(reversed(timeline)) # Oldest to newest
def _format_size(self, size_bytes: int) -> str: def _format_size(self, size_bytes: float) -> str:
"""Format file size in human readable format""" """Format file size in human readable format"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']: for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size_bytes < 1024.0: if size_bytes < 1024.0:
+15 -12
View File
@@ -6,7 +6,7 @@ import shutil
import tempfile import tempfile
import asyncio import asyncio
from aiohttp import web, ClientError from aiohttp import web, ClientError
from typing import Dict, List from typing import Any, Dict, List, cast
from ..utils.settings_paths import ensure_settings_file from ..utils.settings_paths import ensure_settings_file
from ..services.downloader import get_downloader from ..services.downloader import get_downloader
@@ -468,8 +468,9 @@ class UpdateRoutes:
logger.error(f"Failed to fetch release info: {data}") logger.error(f"Failed to fetch release info: {data}")
return False, "" return False, ""
zip_url = data.get("zipball_url") release_payload = cast(dict[str, Any], data)
version = data.get("tag_name", "unknown") zip_url = release_payload.get("zipball_url", "")
version = release_payload.get("tag_name", "unknown")
# Download ZIP to temporary file # Download ZIP to temporary file
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_zip: with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_zip:
@@ -580,9 +581,10 @@ class UpdateRoutes:
logger.warning("Failed to fetch GitHub commit: %s", data) logger.warning("Failed to fetch GitHub commit: %s", data)
return "main", [], 0, "" return "main", [], 0, ""
commit_sha = data.get('sha', '')[:7] commit_payload = cast(dict[str, Any], data)
commit_message = data.get('commit', {}).get('message', '') commit_sha = commit_payload.get('sha', '')[:7]
commit_date = data.get('commit', {}).get('committer', {}).get('date', '')[:10] commit_message = commit_payload.get('commit', {}).get('message', '')
commit_date = commit_payload.get('commit', {}).get('committer', {}).get('date', '')[:10]
version = f"main-{commit_sha}" version = f"main-{commit_sha}"
changelog = [commit_message] if commit_message else [] changelog = [commit_message] if commit_message else []
@@ -598,10 +600,11 @@ class UpdateRoutes:
custom_headers={'Accept': 'application/vnd.github+json'} custom_headers={'Accept': 'application/vnd.github+json'}
) )
if c_ok: if c_ok:
if c_data.get('status') in ('ahead', 'diverged'): compare_payload = cast(dict[str, Any], c_data)
behind_by = c_data.get('ahead_by', 0) if compare_payload.get('status') in ('ahead', 'diverged'):
behind_by = compare_payload.get('ahead_by', 0)
else: else:
behind_by = c_data.get('behind_by', 0) behind_by = compare_payload.get('behind_by', 0)
return version, changelog, behind_by, commit_date return version, changelog, behind_by, commit_date
@@ -706,7 +709,7 @@ class UpdateRoutes:
logger.info(f"Successfully updated to {new_version}") logger.info(f"Successfully updated to {new_version}")
return True, new_version return True, new_version
except git.exc.GitError as e: except git.exc.GitError as e: # pyright: ignore[reportAttributeAccessIssue]
logger.error(f"Git error during update: {e}") logger.error(f"Git error during update: {e}")
return False, "" return False, ""
except Exception as e: except Exception as e:
@@ -767,7 +770,7 @@ class UpdateRoutes:
return git_info return git_info
@staticmethod @staticmethod
async def _get_remote_version() -> tuple[str, List[str], List[Dict]]: async def _get_remote_version() -> tuple[str, List[str], List[Dict[str, Any]]]:
""" """
Fetch remote version from GitHub Fetch remote version from GitHub
Returns: Returns:
@@ -789,7 +792,7 @@ class UpdateRoutes:
# Parse releases # Parse releases
releases = [] releases = []
for i, release in enumerate(data): for i, release in enumerate(cast(list[dict[str, Any]], data)):
version = release.get('tag_name', '') version = release.get('tag_name', '')
if not version.startswith('v'): if not version.startswith('v'):
version = f"v{version}" version = f"v{version}"
+1 -1
View File
@@ -117,7 +117,7 @@ def _render_prompt(template: str, variables: Dict[str, Any]) -> str:
Uses simple regex substitution no Jinja2 dependency needed. Uses simple regex substitution no Jinja2 dependency needed.
""" """
def replace(match: re.Match) -> str: def replace(match: re.Match[str]) -> str:
key = match.group(1).strip() key = match.group(1).strip()
value = variables.get(key, "") value = variables.get(key, "")
if isinstance(value, (dict, list)): if isinstance(value, (dict, list)):
+1 -1
View File
@@ -295,7 +295,7 @@ class PostProcessor:
normalises every tag to lowercase for case-insensitive dedup. normalises every tag to lowercase for case-insensitive dedup.
""" """
merged: List[str] = [] merged: List[str] = []
seen: set = set() seen: set[str] = set()
for tag in list(existing) + list(new): for tag in list(existing) + list(new):
t = tag.strip().lower() t = tag.strip().lower()
if t and t not in seen: if t and t not in seen:
+1 -1
View File
@@ -49,7 +49,7 @@ _FRONTMATTER_RE = re.compile(
) )
def _parse_skill_file(path: Path) -> tuple[dict, str]: def _parse_skill_file(path: Path) -> tuple[dict[str, Any], str]:
"""Read a prompt definition file (``prompt.md`` or legacy ``SKILL.md``) and """Read a prompt definition file (``prompt.md`` or legacy ``SKILL.md``) and
return (frontmatter_dict, body_text). return (frontmatter_dict, body_text).
@@ -9,7 +9,7 @@ from __future__ import annotations
import html as html_module import html as html_module
import re import re
from typing import List, Tuple from typing import Any, List, Tuple
_REPO_URL_PATTERN = re.compile(r"https?://huggingface\.co/([^/]+/[^/]+)") _REPO_URL_PATTERN = re.compile(r"https?://huggingface\.co/([^/]+/[^/]+)")
@@ -18,10 +18,10 @@ _REPO_URL_PATTERN = re.compile(r"https?://huggingface\.co/([^/]+/[^/]+)")
def extract_simple_markdown_images( def extract_simple_markdown_images(
markdown_text: str, markdown_text: str,
repo: str, repo: str,
existing_urls: set | None = None, existing_urls: set[str] | None = None,
default_width: int = 512, default_width: int = 512,
default_height: int = 512, default_height: int = 512,
) -> list[dict]: ) -> list[dict[str, Any]]:
"""Extract standalone markdown images from the README body. """Extract standalone markdown images from the README body.
Matches ``![alt](url)`` on lines that are NOT part of a markdown table Matches ``![alt](url)`` on lines that are NOT part of a markdown table
@@ -36,8 +36,8 @@ def extract_simple_markdown_images(
return [] return []
base_url = f"https://huggingface.co/{repo}/resolve/main" base_url = f"https://huggingface.co/{repo}/resolve/main"
images: list[dict] = [] images: list[dict[str, Any]] = []
seen_urls: set = set(existing_urls) if existing_urls else set() seen_urls: set[str] = set(existing_urls) if existing_urls else set()
# Collect lines that are NOT inside fenced code blocks # Collect lines that are NOT inside fenced code blocks
lines = markdown_text.split("\n") lines = markdown_text.split("\n")
@@ -86,10 +86,10 @@ def extract_simple_markdown_images(
def extract_html_img_tags( def extract_html_img_tags(
markdown_text: str, markdown_text: str,
repo: str, repo: str,
existing_urls: set | None = None, existing_urls: set[str] | None = None,
default_width: int = 512, default_width: int = 512,
default_height: int = 512, default_height: int = 512,
) -> list[dict]: ) -> list[dict[str, Any]]:
"""Extract image URLs from HTML ``<img src=\"...\">`` tags in the README. """Extract image URLs from HTML ``<img src=\"...\">`` tags in the README.
Many HF collection repos (e.g. ``deadman44/Z-Image_LoRA``) use raw HTML Many HF collection repos (e.g. ``deadman44/Z-Image_LoRA``) use raw HTML
@@ -103,8 +103,8 @@ def extract_html_img_tags(
return [] return []
base_url = f"https://huggingface.co/{repo}/resolve/main" base_url = f"https://huggingface.co/{repo}/resolve/main"
images: list[dict] = [] images: list[dict[str, Any]] = []
seen_urls: set = set(existing_urls) if existing_urls else set() seen_urls: set[str] = set(existing_urls) if existing_urls else set()
for m in re.finditer( for m in re.finditer(
r'<img\s[^>]*src=\"([^\"]+)\"', r'<img\s[^>]*src=\"([^\"]+)\"',
@@ -175,7 +175,7 @@ def extract_gallery_images(
repo: str, repo: str,
default_width: int = 512, default_width: int = 512,
default_height: int = 512, default_height: int = 512,
) -> List[dict]: ) -> List[dict[str, Any]]:
"""Extract widget/gallery images from the YAML frontmatter of a HF README. """Extract widget/gallery images from the YAML frontmatter of a HF README.
Args: Args:
@@ -196,7 +196,7 @@ def extract_gallery_images(
if not frontmatter: if not frontmatter:
return [] return []
images: List[dict] = [] images: List[dict[str, Any]] = []
base_url = f"https://huggingface.co/{repo}/resolve/main" base_url = f"https://huggingface.co/{repo}/resolve/main"
w = default_width or 512 w = default_width or 512
h = default_height or 512 h = default_height or 512
@@ -258,7 +258,7 @@ def extract_gallery_images(
text = raw_text text = raw_text
if url: if url:
image: dict = { image: dict[str, Any] = {
"url": url, "url": url,
"type": "image", "type": "image",
"nsfwLevel": 0, "nsfwLevel": 0,
@@ -276,10 +276,10 @@ def extract_gallery_images(
def extract_gallery_table_images( def extract_gallery_table_images(
markdown_text: str, markdown_text: str,
repo: str, repo: str,
existing_urls: set | None = None, existing_urls: set[str] | None = None,
default_width: int = 512, default_width: int = 512,
default_height: int = 512, default_height: int = 512,
) -> list[dict]: ) -> list[dict[str, Any]]:
"""Extract images from ``| Preview | Prompt |`` markdown gallery tables. """Extract images from ``| Preview | Prompt |`` markdown gallery tables.
Many HF READMEs include a sample-gallery table in the body (outside Many HF READMEs include a sample-gallery table in the body (outside
@@ -295,8 +295,8 @@ def extract_gallery_table_images(
return [] return []
base_url = f"https://huggingface.co/{repo}/resolve/main" base_url = f"https://huggingface.co/{repo}/resolve/main"
images: list[dict] = [] images: list[dict[str, Any]] = []
seen_urls: set = set(existing_urls) if existing_urls else set() seen_urls: set[str] = set(existing_urls) if existing_urls else set()
lines = markdown_text.split("\n") lines = markdown_text.split("\n")
n = len(lines) n = len(lines)
i = 0 i = 0
@@ -514,7 +514,7 @@ def _strip_standalone_images(text: str) -> str:
URL was stripped entirely, making it impossible for the LLM to return URL was stripped entirely, making it impossible for the LLM to return
a ``preview_url`` for repos that use HTML ``<img>`` tags exclusively. a ``preview_url`` for repos that use HTML ``<img>`` tags exclusively.
""" """
def _img_to_md(match: re.Match) -> str: def _img_to_md(match: re.Match[str]) -> str:
"""Convert an ``<img>`` tag to markdown image syntax ``![alt](src)``.""" """Convert an ``<img>`` tag to markdown image syntax ``![alt](src)``."""
tag = match.group(0) tag = match.group(0)
src_m = re.search(r'src="([^"]+)"', tag) or re.search(r"src='([^']+)'", tag) src_m = re.search(r'src="([^"]+)"', tag) or re.search(r"src='([^']+)'", tag)
@@ -942,7 +942,7 @@ def _strip_badge_images(text: str) -> str:
"twitter", "colab", "gradio", "space", "twitter", "colab", "gradio", "space",
) )
def _should_remove(m: re.Match) -> str: def _should_remove(m: re.Match[str]) -> str:
alt = (m.group(1) or "").lower() alt = (m.group(1) or "").lower()
for kw in badge_keywords: for kw in badge_keywords:
if kw in alt: if kw in alt:
+135 -7
View File
@@ -1,3 +1,7 @@
# pyright: reportImportCycles=false
# Lazy (function-local) imports still count as static edges in basedpyright's
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -7,6 +11,7 @@ import os
import secrets import secrets
import shutil import shutil
import socket import socket
import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -20,10 +25,43 @@ from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Maximum times the download poll loop will re-schedule a transfer after it
# is lost (daemon restart / RPC outage) before failing the download.
MAX_TRANSFER_RECOVERY_ATTEMPTS = 2
# stderr lines matching these markers indicate a disk write failure inside
# aria2 (piece cache flush or raw file write). They are promoted to INFO so
# the root cause (disk full, permission denied, file locked by another
# process, ...) is visible in the default logs; all other stderr output stays
# at DEBUG to avoid noise.
_DISK_WRITE_ERROR_MARKERS = (
# aria2 wrapper messages (write disk cache flush path)
"write disk cache flush failure",
"error when trying to flush write cache",
"failed to write into the file",
"failed to open the file",
"failed to seek the file",
# underlying root-cause phrases reported via "cause: ..." (POSIX + Windows)
"no space left on device",
"not enough space on the disk",
"input/output error",
"permission denied",
"access is denied",
"disk quota exceeded",
"used by another process",
"sharing violation",
)
# Minimum interval between INFO-level reports of the same stderr line so a
# repeated failure (e.g. aria2 retrying against a full disk) does not spam
# the log.
STDERR_ERROR_REPORT_INTERVAL = 60.0
def _try_certifi_ca_path() -> str | None: def _try_certifi_ca_path() -> str | None:
"""Return the certifi CA bundle path if available, else None.""" """Return the certifi CA bundle path if available, else None."""
try: try:
import certifi # type: ignore[import-untyped] import certifi # pyright: ignore[reportMissingTypeStubs]
path = certifi.where() path = certifi.where()
if os.path.isfile(path): if os.path.isfile(path):
@@ -81,10 +119,12 @@ class Aria2Downloader:
self._rpc_session: Optional[aiohttp.ClientSession] = None self._rpc_session: Optional[aiohttp.ClientSession] = None
self._rpc_session_lock = asyncio.Lock() self._rpc_session_lock = asyncio.Lock()
self._process_lock = asyncio.Lock() self._process_lock = asyncio.Lock()
self._register_lock = asyncio.Lock()
self._transfers: Dict[str, Aria2Transfer] = {} self._transfers: Dict[str, Aria2Transfer] = {}
self._poll_interval = 0.5 self._poll_interval = 0.5
self._state_store = Aria2TransferStateStore() self._state_store = Aria2TransferStateStore()
self._stderr_reader_task: Optional[asyncio.Task] = None self._stderr_reader_task: Optional[asyncio.Task[Any]] = None
self._stderr_error_report: Dict[str, float] = {}
@property @property
def is_running(self) -> bool: def is_running(self) -> bool:
@@ -99,26 +139,58 @@ class Aria2Downloader:
progress_callback=None, progress_callback=None,
headers: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None,
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
"""Download a file using aria2 RPC and wait for completion.""" """Download a file using aria2 RPC and wait for completion.
The poll loop is self-healing: when the in-memory transfer entry
disappears (e.g. another download restarted the daemon and
``close()`` cleared ``_transfers``) or the RPC becomes unreachable,
the transfer is re-scheduled with ``continue=true`` so the download
resumes from the on-disk ``.aria2`` control file. Recovery is bounded
by ``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
"""
await self._ensure_process() await self._ensure_process()
save_path = os.path.abspath(save_path) save_path = os.path.abspath(save_path)
async with self._register_lock:
transfer = self._transfers.get(download_id) transfer = self._transfers.get(download_id)
if transfer is None or os.path.abspath(transfer.save_path) != save_path: if transfer is None or os.path.abspath(transfer.save_path) != save_path:
gid = await self._schedule_download( transfer = await self._register_transfer(
url, url,
save_path, save_path,
download_id=download_id, download_id=download_id,
headers=headers, headers=headers,
) )
transfer = Aria2Transfer(gid=gid, save_path=save_path)
self._transfers[download_id] = transfer
recovery_attempts = 0
try: try:
while True: while True:
try:
status = await self._get_status_with_retry(download_id) status = await self._get_status_with_retry(download_id)
except Aria2Error:
status = None
if status is None: if status is None:
if recovery_attempts >= MAX_TRANSFER_RECOVERY_ATTEMPTS:
return False, "aria2 download not found" return False, "aria2 download not found"
recovery_attempts += 1
logger.warning(
"aria2 transfer %s lost; re-scheduling with resume "
"(attempt %d/%d)",
download_id,
recovery_attempts,
MAX_TRANSFER_RECOVERY_ATTEMPTS,
)
await asyncio.sleep(1.0)
await self._ensure_process()
async with self._register_lock:
transfer = await self._register_transfer(
url,
save_path,
download_id=download_id,
headers=headers,
)
continue
snapshot = self._build_progress_snapshot(status) snapshot = self._build_progress_snapshot(status)
if progress_callback is not None: if progress_callback is not None:
@@ -135,6 +207,8 @@ class Aria2Downloader:
await asyncio.sleep(self._poll_interval) await asyncio.sleep(self._poll_interval)
finally: finally:
current = self._transfers.get(download_id)
if current is not None and current.gid == transfer.gid:
self._transfers.pop(download_id, None) self._transfers.pop(download_id, None)
async def _get_status_with_retry( async def _get_status_with_retry(
@@ -190,7 +264,7 @@ class Aria2Downloader:
download_id, download_id,
) )
options: Dict[str, str] = { options: Dict[str, Any] = {
"dir": save_dir, "dir": save_dir,
"out": out_name, "out": out_name,
"continue": "true", "continue": "true",
@@ -238,6 +312,25 @@ class Aria2Downloader:
) )
return gid return gid
async def _register_transfer(
self,
url: str,
save_path: str,
*,
download_id: str,
headers: Optional[Dict[str, str]] = None,
) -> Aria2Transfer:
"""Schedule a download and track it in the in-memory transfer registry."""
gid = await self._schedule_download(
url,
save_path,
download_id=download_id,
headers=headers,
)
transfer = Aria2Transfer(gid=gid, save_path=os.path.abspath(save_path))
self._transfers[download_id] = transfer
return transfer
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]: async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
"""Return the raw aria2 status payload for a known download.""" """Return the raw aria2 status payload for a known download."""
@@ -385,16 +478,51 @@ class Aria2Downloader:
blocks, which freezes the entire ``aria2c`` process including its blocks, which freezes the entire ``aria2c`` process including its
RPC handler. This background task reads lines from stderr as they RPC handler. This background task reads lines from stderr as they
arrive and forwards them to Python's logger. arrive and forwards them to Python's logger.
Lines that indicate a disk write failure (e.g. the "cause: No space
left on device" line that follows "Write disk cache flush failure")
are promoted to INFO so the root cause is visible without enabling
debug logging; every other line stays at DEBUG to avoid noise.
""" """
try: try:
assert self._process is not None and self._process.stderr is not None assert self._process is not None and self._process.stderr is not None
async for line in self._process.stderr: async for line in self._process.stderr:
text = line.decode("utf-8", errors="replace").rstrip() text = line.decode("utf-8", errors="replace").rstrip()
if text: if text:
if self._is_disk_write_error(text):
self._report_stderr_error(text)
else:
logger.debug("aria2 stderr: %s", text) logger.debug("aria2 stderr: %s", text)
except Exception: except Exception:
pass pass
@staticmethod
def _is_disk_write_error(text: str) -> bool:
lowered = text.lower()
return any(marker in lowered for marker in _DISK_WRITE_ERROR_MARKERS)
def _report_stderr_error(self, text: str) -> None:
"""INFO-log a disk write failure line, rate-limited per line text.
aria2 re-emits the same error chain on every poll/retry while the
underlying condition persists; only the first occurrence within
``STDERR_ERROR_REPORT_INTERVAL`` seconds is promoted to INFO.
"""
now = time.monotonic()
last = self._stderr_error_report.get(text)
if last is not None and now - last < STDERR_ERROR_REPORT_INTERVAL:
logger.debug("aria2 stderr (repeated disk write error): %s", text)
return
# Drop entries older than the window so the map stays bounded even
# during a long disk-full episode (piece indexes change per line).
self._stderr_error_report = {
line: timestamp
for line, timestamp in self._stderr_error_report.items()
if now - timestamp < STDERR_ERROR_REPORT_INTERVAL
}
self._stderr_error_report[text] = now
logger.info("aria2 disk write failure: %s", text)
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None: async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
try: try:
result = callback(snapshot, snapshot) result = callback(snapshot, snapshot)
+3 -3
View File
@@ -8,7 +8,7 @@ from filename, base_model, and CivitAI version name — no manual tagging requir
from __future__ import annotations from __future__ import annotations
import re import re
from typing import Dict, List, Set from typing import Any, Dict, List, Set
# ── Tag category definitions ────────────────────────────────────────── # ── Tag category definitions ──────────────────────────────────────────
# Each category maps a display label to a regex pattern. # Each category maps a display label to a regex pattern.
@@ -52,7 +52,7 @@ AUTO_TAG_GROUPS = {
DEFAULT_ENABLED_GROUPS = {"mode", "video"} DEFAULT_ENABLED_GROUPS = {"mode", "video"}
def _collect_sources(model_data: Dict) -> List[str]: def _collect_sources(model_data: Dict[str, Any]) -> List[str]:
"""Collect all text sources from model data for tag matching.""" """Collect all text sources from model data for tag matching."""
sources: List[str] = [] sources: List[str] = []
@@ -73,7 +73,7 @@ def _collect_sources(model_data: Dict) -> List[str]:
return sources return sources
def extract_auto_tags(model_data: Dict) -> List[str]: def extract_auto_tags(model_data: Dict[str, Any]) -> List[str]:
"""Extract auto-detected tags from model metadata. """Extract auto-detected tags from model metadata.
Uses a two-layer approach: Uses a two-layer approach:
+144
View File
@@ -0,0 +1,144 @@
# pyright: reportImportCycles=false
# Lazy (function-local) imports still count as static edges in basedpyright's
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
"""Backfill the AutoV3 checked state for models loaded from a persisted snapshot.
The SQLite persistent cache predates the AutoV3 feature, so entries hydrated
from it have a NULL ``autov3`` column (the "not checked yet" state). This
service computes the embedded AutoV3 hash for each such model once per
process and persists it through the scanner's single write path
(:meth:`ModelScanner.update_autov3_for_model`), marking every visited row so a
subsequent run finds nothing left to do.
Three-state contract honored here:
- ``NULL`` (sqlite) / absent (dict) = not checked yet backfill computes it
- ``''`` (sqlite/dict) / JSON null = checked, no value available never recompute
- 12-char lowercase hex = value never recompute
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import threading
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING: # pragma: no cover - type-check only; runtime imports are local
from .model_scanner import ModelScanner
logger = logging.getLogger(__name__)
def _resolve_autov3(file_path: str) -> str:
"""Resolve the AutoV3 hash for a model file.
Prefers the Civitai AutoV3 reported for the file whose SHA256 matches
(the authoritative value for recipe matching); falls back to the embedded
safetensors header hash. Returns ``''`` when neither is available.
"""
try:
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
if os.path.exists(metadata_path):
with open(metadata_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
if isinstance(payload, dict):
from ..utils.models import autov3_from_civitai_files # local import avoids cycles
sha256 = (payload.get("sha256") or "").lower()
civitai_autov3 = autov3_from_civitai_files(payload.get("civitai"), sha256)
if civitai_autov3:
return civitai_autov3
except Exception:
pass
from ..utils.file_utils import calculate_autov3 # local import avoids cycles
return calculate_autov3(file_path) or ""
class Autov3BackfillService:
"""Compute and persist AutoV3 hashes for models missing a checked state."""
_instance: Optional["Autov3BackfillService"] = None
_instance_lock = threading.Lock()
def __init__(self) -> None:
# Re-entrancy guard per model type: scanners for different model types
# initialize concurrently (lora_manager.py), so a global guard would
# silently skip every type but the first to start. Each model type
# runs its own backfill; a duplicate trigger for the same type no-ops.
self._running_types: set[str] = set()
@classmethod
def get_instance(cls) -> "Autov3BackfillService":
"""Return the process-wide singleton instance."""
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def backfill(self, scanner: "ModelScanner") -> int:
"""Compute AutoV3 for every un-checked model of ``scanner.model_type``.
Each candidate file is read once via :func:`~py.utils.file_utils.calculate_autov3`
(cheap: safetensors header only) and the result is persisted through
``scanner.update_autov3_for_model``. Files that no longer exist on
disk are skipped they are intentionally NOT marked, because scanner
cleanup removes the stale row later.
Returns:
The number of models successfully updated. Never raises; on any
failure a warning is logged and ``0`` is returned. A duplicate
trigger for a model type that is already being backfilled returns
``0`` immediately; different model types run concurrently.
"""
model_type = scanner.model_type
if model_type in self._running_types:
return 0
self._running_types.add(model_type)
try:
# Local imports avoid import cycles at module load time.
from .persistent_model_cache import get_persistent_cache
from ..utils.file_utils import calculate_autov3
persistent = getattr(scanner, "_persistent_cache", None) or get_persistent_cache()
paths = persistent.get_models_missing_autov3(model_type)
loop = asyncio.get_running_loop()
count = 0
for path in paths:
# A file that no longer exists must not be marked; scanner
# cleanup removes the stale row later. The existence check and
# hash resolution run in the executor so the loop stays
# responsive to API requests while the backfill iterates a
# large library.
if not await loop.run_in_executor(None, os.path.exists, path):
continue
autov3 = await loop.run_in_executor(None, _resolve_autov3, path)
if await scanner.update_autov3_for_model(model_type, path, autov3):
count += 1
if paths:
logger.info(
"AutoV3 backfill: updated %d/%d models for %s",
count,
len(paths),
model_type,
)
else:
# Steady state after the first run: nothing left to backfill.
logger.debug("AutoV3 backfill: nothing to process for %s", model_type)
return count
except Exception as exc:
logger.warning(
"AutoV3 backfill failed for %s: %s",
getattr(scanner, "model_type", "?"),
exc,
)
return 0
finally:
self._running_types.discard(model_type)
+4
View File
@@ -1,3 +1,7 @@
# pyright: reportImportCycles=false
# Lazy (function-local) imports still count as static edges in basedpyright's
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
+163 -69
View File
@@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
import asyncio import asyncio
import re import re
import random import random
from typing import Any, Dict, List, Optional, Type, Union, TYPE_CHECKING from typing import Any, Awaitable, Dict, List, Optional, Type, Union, TYPE_CHECKING, cast
import logging import logging
import os import os
import time import time
@@ -70,24 +70,24 @@ class BaseModelService(ABC):
page: int, page: int,
page_size: int, page_size: int,
sort_by: str = "name", sort_by: str = "name",
folder: str = None, folder: str | None = None,
folder_include: list = None, folder_include: list[str] | None = None,
folder_exclude: list = None, folder_exclude: list[str] | None = None,
search: str = None, search: str | None = None,
fuzzy_search: bool = False, fuzzy_search: bool = False,
base_models: list = None, base_models: list[str] | None = None,
model_types: list = None, model_types: list[str] | None = None,
tags: Optional[Dict[str, str]] = None, tags: Optional[Dict[str, str]] = None,
auto_tags: Optional[Dict[str, str]] = None, auto_tags: Optional[Dict[str, str]] = None,
search_options: dict = None, search_options: dict[str, Any] | None = None,
hash_filters: dict = None, hash_filters: dict[str, Any] | None = None,
favorites_only: bool = False, favorites_only: bool = False,
update_available_only: bool = False, update_available_only: bool = False,
credit_required: Optional[bool] = None, credit_required: Optional[bool] = None,
allow_selling_generated_content: Optional[bool] = None, allow_selling_generated_content: Optional[bool] = None,
tag_logic: str = "any", tag_logic: str = "any",
**kwargs, **kwargs,
) -> Dict: ) -> Dict[str, Any]:
"""Get paginated and filtered model data""" """Get paginated and filtered model data"""
overall_start = time.perf_counter() overall_start = time.perf_counter()
@@ -178,8 +178,8 @@ class BaseModelService(ABC):
ufs = self.settings.get("version_grouping", "same_base") ufs = self.settings.get("version_grouping", "same_base")
group_by_base = ufs == "same_base" group_by_base = ufs == "same_base"
model_groups: Dict[Any, List[Dict]] = {} model_groups: Dict[Any, List[Dict[str, Any]]] = {}
ungrouped_standalone: List[Dict] = [] ungrouped_standalone: List[Dict[str, Any]] = []
for item in sorted_data: for item in sorted_data:
mid = self._extract_group_key(item) mid = self._extract_group_key(item)
if mid is None: if mid is None:
@@ -249,7 +249,7 @@ class BaseModelService(ABC):
filter_duration = time.perf_counter() - t1 filter_duration = time.perf_counter() - t1
post_filter_count = len(filtered_data) post_filter_count = len(filtered_data)
annotated_for_filter: Optional[List[Dict]] = None annotated_for_filter: Optional[List[Dict[str, Any]]] = None
t2 = time.perf_counter() t2 = time.perf_counter()
if update_available_only: if update_available_only:
annotated_for_filter = await self._annotate_update_flags(filtered_data) annotated_for_filter = await self._annotate_update_flags(filtered_data)
@@ -296,11 +296,11 @@ class BaseModelService(ABC):
page: int, page: int,
page_size: int, page_size: int,
sort_by: str = "name", sort_by: str = "name",
search: str = None, search: str | None = None,
fuzzy_search: bool = False, fuzzy_search: bool = False,
search_options: dict = None, search_options: dict[str, Any] | None = None,
**kwargs, **kwargs,
) -> Dict: ) -> Dict[str, Any]:
"""Get paginated excluded model data.""" """Get paginated excluded model data."""
excluded_paths = list(self.scanner.get_excluded_models()) excluded_paths = list(self.scanner.get_excluded_models())
excluded_entries: List[Dict[str, Any]] = [] excluded_entries: List[Dict[str, Any]] = []
@@ -326,7 +326,7 @@ class BaseModelService(ABC):
] ]
persist_current_cache = getattr(self.scanner, "_persist_current_cache", None) persist_current_cache = getattr(self.scanner, "_persist_current_cache", None)
if callable(persist_current_cache): if callable(persist_current_cache):
await persist_current_cache() await cast(Awaitable[Any], persist_current_cache())
excluded_entries = self._sort_entries(excluded_entries, sort_by) excluded_entries = self._sort_entries(excluded_entries, sort_by)
@@ -444,39 +444,50 @@ class BaseModelService(ABC):
return entry return entry
async def _apply_hash_filters( async def _apply_hash_filters(
self, data: List[Dict], hash_filters: Dict self, data: List[Dict[str, Any]], hash_filters: Dict[str, Any]
) -> List[Dict]: ) -> List[Dict[str, Any]]:
"""Apply hash-based filtering""" """Apply hash-based filtering (SHA256 and AutoV3)."""
def matches_hash_set(item: Dict[str, Any], hash_set: set[str]) -> bool:
"""Check whether an item matches any hash in the set.
Compares the item's ``sha256`` field and its non-empty ``autov3``
field, both case-insensitively.
"""
if item.get("sha256", "").lower() in hash_set:
return True
autov3 = item.get("autov3", "")
return bool(autov3) and autov3.lower() in hash_set
single_hash = hash_filters.get("single_hash") single_hash = hash_filters.get("single_hash")
multiple_hashes = hash_filters.get("multiple_hashes") multiple_hashes = hash_filters.get("multiple_hashes")
if single_hash: if single_hash:
# Filter by single hash # Filter by single hash (SHA256 or AutoV3)
single_hash = single_hash.lower()
return [ return [
item for item in data if item.get("sha256", "").lower() == single_hash item for item in data if matches_hash_set(item, {single_hash.lower()})
] ]
elif multiple_hashes: elif multiple_hashes:
# Filter by multiple hashes # Filter by multiple hashes (SHA256 or AutoV3)
hash_set = set(hash.lower() for hash in multiple_hashes) hash_set = {hash.lower() for hash in multiple_hashes}
return [item for item in data if item.get("sha256", "").lower() in hash_set] return [item for item in data if matches_hash_set(item, hash_set)]
return data return data
async def _apply_common_filters( async def _apply_common_filters(
self, self,
data: List[Dict], data: List[Dict[str, Any]],
folder: str = None, folder: str | None = None,
folder_include: list = None, folder_include: list[str] | None = None,
folder_exclude: list = None, folder_exclude: list[str] | None = None,
base_models: list = None, base_models: list[str] | None = None,
model_types: list = None, model_types: list[str] | None = None,
tags: Optional[Dict[str, str]] = None, tags: Optional[Dict[str, str]] = None,
auto_tags: Optional[Dict[str, str]] = None, auto_tags: Optional[Dict[str, str]] = None,
favorites_only: bool = False, favorites_only: bool = False,
search_options: dict = None, search_options: dict[str, Any] | None = None,
tag_logic: str = "any", tag_logic: str = "any",
) -> List[Dict]: ) -> List[Dict[str, Any]]:
"""Apply common filters that work across all model types""" """Apply common filters that work across all model types"""
normalized_options = self.search_strategy.normalize_options(search_options) normalized_options = self.search_strategy.normalize_options(search_options)
criteria = FilterCriteria( criteria = FilterCriteria(
@@ -495,24 +506,24 @@ class BaseModelService(ABC):
async def _apply_search_filters( async def _apply_search_filters(
self, self,
data: List[Dict], data: List[Dict[str, Any]],
search: str, search: str,
fuzzy_search: bool, fuzzy_search: bool,
search_options: dict, search_options: dict[str, Any] | None,
) -> List[Dict]: ) -> List[Dict[str, Any]]:
"""Apply search filtering""" """Apply search filtering"""
normalized_options = self.search_strategy.normalize_options(search_options) normalized_options = self.search_strategy.normalize_options(search_options)
return self.search_strategy.apply( return self.search_strategy.apply(
data, search, normalized_options, fuzzy_search data, search, normalized_options, fuzzy_search
) )
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]: async def _apply_specific_filters(self, data: List[Dict[str, Any]], **kwargs) -> List[Dict[str, Any]]:
"""Apply model-specific filters - to be overridden by subclasses if needed""" """Apply model-specific filters - to be overridden by subclasses if needed"""
return data return data
async def _apply_credit_required_filter( async def _apply_credit_required_filter(
self, data: List[Dict], credit_required: bool self, data: List[Dict[str, Any]], credit_required: bool
) -> List[Dict]: ) -> List[Dict[str, Any]]:
"""Apply credit required filtering based on license_flags. """Apply credit required filtering based on license_flags.
Args: Args:
@@ -542,8 +553,8 @@ class BaseModelService(ABC):
return filtered_data return filtered_data
async def _apply_allow_selling_filter( async def _apply_allow_selling_filter(
self, data: List[Dict], allow_selling: bool self, data: List[Dict[str, Any]], allow_selling: bool
) -> List[Dict]: ) -> List[Dict[str, Any]]:
"""Apply allow selling generated content filtering based on license_flags. """Apply allow selling generated content filtering based on license_flags.
Args: Args:
@@ -575,8 +586,8 @@ class BaseModelService(ABC):
async def _annotate_update_flags( async def _annotate_update_flags(
self, self,
items: List[Dict], items: List[Dict[str, Any]],
) -> List[Dict]: ) -> List[Dict[str, Any]]:
"""Attach an update_available flag to each response item. """Attach an update_available flag to each response item.
Items without a civitai model id default to False. Items without a civitai model id default to False.
@@ -591,7 +602,7 @@ class BaseModelService(ABC):
item["update_available"] = False item["update_available"] = False
return annotated return annotated
id_to_items: Dict[int, List[Dict]] = {} id_to_items: Dict[int, List[Dict[str, Any]]] = {}
ordered_ids: List[int] = [] ordered_ids: List[int] = []
for item in annotated: for item in annotated:
model_id = self._extract_model_id(item) model_id = self._extract_model_id(item)
@@ -622,15 +633,25 @@ class BaseModelService(ABC):
except Exception: except Exception:
hide_early_access = False hide_early_access = False
# Check user setting for hiding permanent paid updates
hide_paid = False
try:
hide_paid = bool(self.settings.get("hide_paid_updates", False))
except Exception:
hide_paid = False
records = None records = None
resolved: Optional[Dict[int, bool]] = None resolved: Optional[Dict[int, bool]] = None
if same_base_mode: if same_base_mode:
record_method = getattr(self.update_service, "get_records_bulk", None) record_method = getattr(self.update_service, "get_records_bulk", None)
if callable(record_method): if callable(record_method):
try: try:
records = await record_method(self.model_type, ordered_ids) records = await cast(Awaitable[Any], record_method(self.model_type, ordered_ids))
resolved = { resolved = {
model_id: record.has_update(hide_early_access=hide_early_access) model_id: record.has_update(
hide_early_access=hide_early_access,
hide_paid=hide_paid,
)
for model_id, record in records.items() for model_id, record in records.items()
} }
except Exception as exc: except Exception as exc:
@@ -648,11 +669,12 @@ class BaseModelService(ABC):
bulk_method = getattr(self.update_service, "has_updates_bulk", None) bulk_method = getattr(self.update_service, "has_updates_bulk", None)
if callable(bulk_method): if callable(bulk_method):
try: try:
resolved = await bulk_method( resolved = await cast(Awaitable[Any], bulk_method(
self.model_type, self.model_type,
ordered_ids, ordered_ids,
hide_early_access=hide_early_access, hide_early_access=hide_early_access,
) hide_paid=hide_paid,
))
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"Failed to resolve update status in bulk for %s models (%s): %s", "Failed to resolve update status in bulk for %s models (%s): %s",
@@ -666,7 +688,10 @@ class BaseModelService(ABC):
if resolved is None: if resolved is None:
tasks = [ tasks = [
self.update_service.has_update( self.update_service.has_update(
self.model_type, model_id, hide_early_access=hide_early_access self.model_type,
model_id,
hide_early_access=hide_early_access,
hide_paid=hide_paid,
) )
for model_id in ordered_ids for model_id in ordered_ids
] ]
@@ -706,6 +731,7 @@ class BaseModelService(ABC):
threshold_version, threshold_version,
base_model, base_model,
hide_early_access=hide_early_access, hide_early_access=hide_early_access,
hide_paid=hide_paid,
) )
else: else:
flag = default_flag flag = default_flag
@@ -714,7 +740,7 @@ class BaseModelService(ABC):
return annotated return annotated
@staticmethod @staticmethod
def _extract_hf_group_key(item: Dict) -> Optional[str]: def _extract_hf_group_key(item: Dict[str, Any]) -> Optional[str]:
"""Extract `hf:{owner}/{repo}` from item's ``hf_url``, or None.""" """Extract `hf:{owner}/{repo}` from item's ``hf_url``, or None."""
hf_url = item.get("hf_url") if isinstance(item, dict) else None hf_url = item.get("hf_url") if isinstance(item, dict) else None
if not hf_url or not isinstance(hf_url, str): if not hf_url or not isinstance(hf_url, str):
@@ -727,7 +753,7 @@ class BaseModelService(ABC):
return f"hf:{m.group(1)}" return f"hf:{m.group(1)}"
@staticmethod @staticmethod
def _extract_group_key(item: Dict) -> Union[int, str, None]: def _extract_group_key(item: Dict[str, Any]) -> Union[int, str, None]:
"""Return the group identity key: CivitAI modelId (int) or HF repo (str). """Return the group identity key: CivitAI modelId (int) or HF repo (str).
Preference order: Preference order:
@@ -741,7 +767,7 @@ class BaseModelService(ABC):
return BaseModelService._extract_hf_group_key(item) return BaseModelService._extract_hf_group_key(item)
@staticmethod @staticmethod
def _extract_model_id(item: Dict) -> Optional[int]: def _extract_model_id(item: Dict[str, Any]) -> Optional[int]:
civitai = item.get("civitai") if isinstance(item, dict) else None civitai = item.get("civitai") if isinstance(item, dict) else None
if not isinstance(civitai, dict): if not isinstance(civitai, dict):
return None return None
@@ -754,7 +780,7 @@ class BaseModelService(ABC):
return None return None
@staticmethod @staticmethod
def _extract_version_id(item: Dict) -> Optional[int]: def _extract_version_id(item: Dict[str, Any]) -> Optional[int]:
civitai = item.get("civitai") if isinstance(item, dict) else None civitai = item.get("civitai") if isinstance(item, dict) else None
if not isinstance(civitai, dict): if not isinstance(civitai, dict):
return None return None
@@ -767,7 +793,7 @@ class BaseModelService(ABC):
return None return None
@staticmethod @staticmethod
def _extract_base_model(item: Dict) -> Optional[str]: def _extract_base_model(item: Dict[str, Any]) -> Optional[str]:
value = item.get("base_model") value = item.get("base_model")
if value is None: if value is None:
return None return None
@@ -819,7 +845,7 @@ class BaseModelService(ABC):
return highest_by_base return highest_by_base
def _paginate(self, data: List[Dict], page: int, page_size: int) -> Dict: def _paginate(self, data: List[Dict[str, Any]], page: int, page_size: int) -> Dict[str, Any]:
"""Apply pagination to filtered data""" """Apply pagination to filtered data"""
total_items = len(data) total_items = len(data)
start_idx = (page - 1) * page_size start_idx = (page - 1) * page_size
@@ -834,7 +860,7 @@ class BaseModelService(ABC):
} }
@abstractmethod @abstractmethod
async def format_response(self, model_data: Dict) -> Optional[Dict]: async def format_response(self, model_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Format model data for API response - must be implemented by subclasses. """Format model data for API response - must be implemented by subclasses.
Subclasses should return None for corrupted entries so the handler Subclasses should return None for corrupted entries so the handler
@@ -843,17 +869,17 @@ class BaseModelService(ABC):
pass pass
# Common service methods that delegate to scanner # Common service methods that delegate to scanner
async def get_top_tags(self, limit: int = 20) -> List[Dict]: async def get_top_tags(self, limit: int = 20) -> List[Dict[str, Any]]:
"""Get top tags sorted by frequency""" """Get top tags sorted by frequency"""
return await self.scanner.get_top_tags(limit) return await self.scanner.get_top_tags(limit)
async def search_tags( async def search_tags(
self, query: str, limit: int = 50 self, query: str, limit: int = 50
) -> List[Dict]: ) -> List[Dict[str, Any]]:
"""Search tags by substring, sorted by frequency""" """Search tags by substring, sorted by frequency"""
return await self.scanner.search_tags(query, limit) return await self.scanner.search_tags(query, limit)
async def get_base_models(self, limit: int = 20) -> List[Dict]: async def get_base_models(self, limit: int = 20) -> List[Dict[str, Any]]:
"""Get base models sorted by frequency""" """Get base models sorted by frequency"""
return await self.scanner.get_base_models(limit) return await self.scanner.get_base_models(limit)
@@ -920,7 +946,7 @@ class BaseModelService(ABC):
"""Get model root directories""" """Get model root directories"""
return self.scanner.get_model_roots() return self.scanner.get_model_roots()
def filter_civitai_data(self, data: Dict, minimal: bool = False) -> Dict: def filter_civitai_data(self, data: Dict[str, Any], minimal: bool = False) -> Dict[str, Any]:
"""Filter relevant fields from CivitAI data""" """Filter relevant fields from CivitAI data"""
if not data: if not data:
return {} return {}
@@ -946,7 +972,7 @@ class BaseModelService(ABC):
) )
return {k: data[k] for k in fields if k in data} return {k: data[k] for k in fields if k in data}
async def get_folder_tree(self, model_root: str) -> Dict: async def get_folder_tree(self, model_root: str) -> Dict[str, Any]:
"""Get hierarchical folder tree for a specific model root""" """Get hierarchical folder tree for a specific model root"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
@@ -975,7 +1001,7 @@ class BaseModelService(ABC):
return tree return tree
async def get_unified_folder_tree(self) -> Dict: async def get_unified_folder_tree(self) -> Dict[str, Any]:
"""Get unified folder tree across all model roots""" """Get unified folder tree across all model roots"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
@@ -1004,7 +1030,7 @@ class BaseModelService(ABC):
return unified_tree return unified_tree
async def get_model_notes(self, model_name: str) -> Optional[dict]: async def get_model_notes(self, model_name: str) -> Optional[dict[str, Any]]:
"""Get notes and file_path for a specific model file. """Get notes and file_path for a specific model file.
Supports both simple names (``OWSMianne_ANIMA_V1``) and full-path Supports both simple names (``OWSMianne_ANIMA_V1``) and full-path
@@ -1136,7 +1162,7 @@ class BaseModelService(ABC):
return {"civitai_url": None, "model_id": None, "version_id": None} return {"civitai_url": None, "model_id": None, "version_id": None}
async def get_model_metadata(self, file_path: str) -> Optional[Dict]: async def get_model_metadata(self, file_path: str) -> Optional[Dict[str, Any]]:
"""Load full metadata for a single model. """Load full metadata for a single model.
Listing/search endpoints return lightweight cache entries; this method performs Listing/search endpoints return lightweight cache entries; this method performs
@@ -1232,7 +1258,7 @@ class BaseModelService(ABC):
return True return True
@staticmethod @staticmethod
def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple: def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple[int, int, int, str]:
"""Sort paths by how well they satisfy the include tokens. """Sort paths by how well they satisfy the include tokens.
Sorts based on path without extension for consistent ordering. Sorts based on path without extension for consistent ordering.
@@ -1259,19 +1285,87 @@ class BaseModelService(ABC):
) )
async def search_relative_paths( async def search_relative_paths(
self, search_term: str, limit: int = 15, offset: int = 0 self,
search_term: str,
limit: int = 15,
offset: int = 0,
*,
folder: Optional[str] = None,
folder_include: Optional[list[str]] = None,
folder_exclude: Optional[list[str]] = None,
base_models: Optional[list[str]] = None,
model_types: Optional[list[str]] = None,
tags: Optional[dict[str, str]] = None,
auto_tags: Optional[dict[str, str]] = None,
tag_logic: str = "any",
credit_required: Optional[bool] = None,
allow_selling_generated_content: Optional[bool] = None,
recursive: bool = True,
apply_filters: bool = False,
) -> List[str]: ) -> List[str]:
"""Search model relative file paths for autocomplete functionality""" """Search model relative file paths for autocomplete functionality.
Optional filter kwargs mirror the filters used by the list endpoint
(/api/lm/{prefix}/list). When no filter kwargs are provided the
behavior is identical to plain token-based path matching.
"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
include_terms, exclude_terms = self._parse_search_tokens(search_term) include_terms, exclude_terms = self._parse_search_tokens(search_term)
data = cache.raw_data
has_filters = any(
[
apply_filters,
folder is not None,
folder_include,
folder_exclude,
base_models,
model_types,
tags,
auto_tags,
credit_required is not None,
allow_selling_generated_content is not None,
]
)
if has_filters:
# Auto-tags are not stored in the scanner cache — they are computed
# on the fly. Pre-compute them only when an auto-tag filter is
# active to avoid mutating cache entries unnecessarily.
if auto_tags:
from .auto_tag_service import extract_auto_tags
for item in data:
if not item.get("auto_tags"):
item["auto_tags"] = extract_auto_tags(item)
criteria = FilterCriteria(
folder=folder,
folder_include=folder_include,
folder_exclude=folder_exclude,
base_models=base_models,
model_types=model_types,
tags=tags,
auto_tags=auto_tags,
search_options={"recursive": recursive},
tag_logic=tag_logic,
)
data = self.filter_set.apply(data, criteria)
if credit_required is not None:
data = await self._apply_credit_required_filter(
data, credit_required
)
if allow_selling_generated_content is not None:
data = await self._apply_allow_selling_filter(
data, allow_selling_generated_content
)
matching_paths = [] matching_paths = []
# Get model roots for path calculation # Get model roots for path calculation
model_roots = self.scanner.get_model_roots() model_roots = self.scanner.get_model_roots()
# Collect all matching paths first (needed for proper sorting and offset) # Collect all matching paths first (needed for proper sorting and offset)
for model in cache.raw_data: for model in data:
file_path = model.get("file_path", "") file_path = model.get("file_path", "")
if not file_path: if not file_path:
continue continue
+28
View File
@@ -59,6 +59,7 @@ class CacheEntryValidator:
'notes': ('', False), 'notes': ('', False),
'usage_tips': ('', False), 'usage_tips': ('', False),
'hash_status': ('completed', False), 'hash_status': ('completed', False),
'autov3': (None, False),
} }
@classmethod @classmethod
@@ -119,6 +120,11 @@ class CacheEntryValidator:
if is_required: if is_required:
errors.append(f"Required field '{field_name}' is missing or None") errors.append(f"Required field '{field_name}' is missing or None")
if auto_repair: if auto_repair:
# A missing optional field whose default is None is already
# semantically equal to its default (e.g. autov3: absent
# means "not checked") — writing None back is a no-op, not
# a repair.
if default_value is not None:
working_entry[field_name] = cls._get_default_copy(default_value) working_entry[field_name] = cls._get_default_copy(default_value)
repaired = True repaired = True
continue continue
@@ -175,6 +181,15 @@ class CacheEntryValidator:
# that invalidates the entry, but we also don't mark it repaired. # that invalidates the entry, but we also don't mark it repaired.
pass pass
# Normalize autov3 to lowercase if needed (optional field, never stripped).
autov3 = working_entry.get('autov3')
if isinstance(autov3, str) and autov3:
normalized_autov3 = autov3.lower()
if normalized_autov3 != autov3:
if auto_repair:
working_entry['autov3'] = normalized_autov3
repaired = True
# Determine if entry is valid # Determine if entry is valid
# Entry is valid if no critical required field errors remain after repair # Entry is valid if no critical required field errors remain after repair
# Critical fields are file_path and sha256 # Critical fields are file_path and sha256
@@ -242,6 +257,19 @@ class CacheEntryValidator:
""" """
expected_type = type(default_value) expected_type = type(default_value)
# Special case: autov3 is optional with a three-state contract.
# None = not checked, "" = checked but unavailable, otherwise a
# 12-character hex string (case-insensitive here; normalized to
# lowercase separately).
if field_name == 'autov3':
if value is None or value == "":
return None
if not isinstance(value, str):
return f"Field 'autov3' should be string or None, got {type(value).__name__}"
if len(value) != 12 or any(c not in '0123456789abcdefABCDEF' for c in value):
return "Field 'autov3' should be a 12-character hex string"
return None
# Special handling for numeric types # Special handling for numeric types
if expected_type == int: if expected_type == int:
if not isinstance(value, (int, float)): if not isinstance(value, (int, float)):
+36 -8
View File
@@ -1,3 +1,7 @@
# pyright: reportImportCycles=false
# Lazy (function-local) imports still count as static edges in basedpyright's
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
import asyncio import asyncio
import json import json
import logging import logging
@@ -6,10 +10,10 @@ from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ..utils.models import CheckpointMetadata from ..utils.models import CheckpointMetadata
from ..utils.file_utils import find_preview_file, normalize_path from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
from ..config import config from ..config import config
from .model_scanner import ModelScanner from .model_scanner import ModelScanner, _is_excluded_dir
from .model_hash_index import ModelHashIndex from .model_hash_index import ModelHashIndex
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -62,6 +66,11 @@ class CheckpointScanner(ModelScanner):
# Find preview image # Find preview image
preview_url = find_preview_file(base_name, dir_path) preview_url = find_preview_file(base_name, dir_path)
# AutoV3 reads only the safetensors header, so it is cheap even for
# large checkpoints; record the checked state at creation time ("" =
# checked but unavailable).
autov3 = calculate_autov3(real_path)
# Create metadata WITHOUT calculating hash # Create metadata WITHOUT calculating hash
metadata = CheckpointMetadata( metadata = CheckpointMetadata(
file_name=base_name, file_name=base_name,
@@ -77,6 +86,7 @@ class CheckpointScanner(ModelScanner):
sub_type="checkpoint", sub_type="checkpoint",
from_civitai=False, # Mark as local model since no hash yet from_civitai=False, # Mark as local model since no hash yet
hash_status="pending", # Mark hash as pending hash_status="pending", # Mark hash as pending
autov3=autov3 or "",
) )
# Save the created metadata # Save the created metadata
@@ -120,7 +130,11 @@ class CheckpointScanner(ModelScanner):
# that queries get_hash_by_filename first) will miss on every # that queries get_hash_by_filename first) will miss on every
# lookup and keep calling back into this method, creating a # lookup and keep calling back into this method, creating a
# tight loop that never populates the index. # tight loop that never populates the index.
self._hash_index.add_entry(metadata.sha256.lower(), file_path) self._hash_index.add_entry(
metadata.sha256.lower(),
file_path,
getattr(metadata, "autov3", None) or None,
)
return metadata.sha256 return metadata.sha256
async with self._hash_calculation_lock: async with self._hash_calculation_lock:
@@ -132,7 +146,11 @@ class CheckpointScanner(ModelScanner):
and metadata.hash_status == "completed" and metadata.hash_status == "completed"
and metadata.sha256 and metadata.sha256
): ):
self._hash_index.add_entry(metadata.sha256.lower(), file_path) self._hash_index.add_entry(
metadata.sha256.lower(),
file_path,
getattr(metadata, "autov3", None) or None,
)
return metadata.sha256 return metadata.sha256
task = self._hash_calculation_tasks.get(real_path) task = self._hash_calculation_tasks.get(real_path)
@@ -185,7 +203,11 @@ class CheckpointScanner(ModelScanner):
if metadata.hash_status == "completed" and metadata.sha256: if metadata.hash_status == "completed" and metadata.sha256:
# Populate the in-memory hash index even for pre-computed # Populate the in-memory hash index even for pre-computed
# hashes, mirroring the fix in calculate_hash_for_model. # hashes, mirroring the fix in calculate_hash_for_model.
self._hash_index.add_entry(metadata.sha256.lower(), file_path) self._hash_index.add_entry(
metadata.sha256.lower(),
file_path,
getattr(metadata, "autov3", None) or None,
)
return metadata.sha256 return metadata.sha256
# Update status to calculating # Update status to calculating
@@ -202,7 +224,11 @@ class CheckpointScanner(ModelScanner):
await MetadataManager.save_metadata(file_path, metadata) await MetadataManager.save_metadata(file_path, metadata)
# Update hash index # Update hash index
self._hash_index.add_entry(sha256.lower(), file_path) self._hash_index.add_entry(
sha256.lower(),
file_path,
getattr(metadata, "autov3", None) or None,
)
# Update the in-memory cache entry so that subsequent # Update the in-memory cache entry so that subsequent
# _persist_current_cache / _save_persistent_cache calls # _persist_current_cache / _save_persistent_cache calls
@@ -216,6 +242,7 @@ class CheckpointScanner(ModelScanner):
if entry.get("file_path") == file_path: if entry.get("file_path") == file_path:
entry["sha256"] = sha256.lower() entry["sha256"] = sha256.lower()
entry["hash_status"] = "completed" entry["hash_status"] = "completed"
self.bump_cache_version()
break break
logger.info(f"Hash calculated for checkpoint: {file_path}") logger.info(f"Hash calculated for checkpoint: {file_path}")
@@ -301,7 +328,8 @@ class CheckpointScanner(ModelScanner):
if not os.path.exists(root_path): if not os.path.exists(root_path):
continue continue
for dirpath, _dirnames, filenames in os.walk(root_path): for dirpath, dirnames, filenames in os.walk(root_path):
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
for filename in filenames: for filename in filenames:
if not filename.endswith(".metadata.json"): if not filename.endswith(".metadata.json"):
continue continue
@@ -405,7 +433,7 @@ class CheckpointScanner(ModelScanner):
roots.extend(config.extra_checkpoints_roots or []) roots.extend(config.extra_checkpoints_roots or [])
roots.extend(config.extra_unet_roots or []) roots.extend(config.extra_unet_roots or [])
# Remove duplicates while preserving order # Remove duplicates while preserving order
seen: set = set() seen: set[str] = set()
unique_roots: List[str] = [] unique_roots: List[str] = []
for root in roots: for root in roots:
if root not in seen: if root not in seen:
+28 -28
View File
@@ -1,6 +1,6 @@
import os import os
import logging import logging
from typing import Dict, Optional from typing import Any, Dict, Optional
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags from .auto_tag_service import extract_auto_tags
@@ -21,58 +21,58 @@ class CheckpointService(BaseModelService):
""" """
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service) super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
async def format_response(self, checkpoint_data: Dict) -> Optional[Dict]: async def format_response(self, model_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Format Checkpoint data for API response. """Format Checkpoint data for API response.
Returns None when the entry is missing critical fields (corrupted cache Returns None when the entry is missing critical fields (corrupted cache
row), so the handler layer can filter it out. See issue #730. row), so the handler layer can filter it out. See issue #730.
""" """
# Guard against corrupted cache entries missing critical fields # Guard against corrupted cache entries missing critical fields
file_path = checkpoint_data.get("file_path") file_path = model_data.get("file_path")
if not file_path or not isinstance(file_path, str): if not file_path or not isinstance(file_path, str):
logger.warning( logger.warning(
"Skipping corrupted checkpoint entry (missing file_path): %s", "Skipping corrupted checkpoint entry (missing file_path): %s",
checkpoint_data.get("file_name", "<unknown>"), model_data.get("file_name", "<unknown>"),
) )
return None return None
# Get sub_type from cache entry (new canonical field) # Get sub_type from cache entry (new canonical field)
sub_type = checkpoint_data.get("sub_type", "checkpoint") sub_type = model_data.get("sub_type", "checkpoint")
file_name = checkpoint_data.get("file_name") or "" file_name = model_data.get("file_name") or ""
model_name = checkpoint_data.get("model_name") or file_name model_name = model_data.get("model_name") or file_name
folder = checkpoint_data.get("folder") or "" folder = model_data.get("folder") or ""
return { return {
"model_name": model_name, "model_name": model_name,
"file_name": file_name, "file_name": file_name,
"preview_url": config.get_preview_static_url(checkpoint_data.get("preview_url", "")), "preview_url": config.get_preview_static_url(model_data.get("preview_url", "")),
"preview_nsfw_level": checkpoint_data.get("preview_nsfw_level", 0), "preview_nsfw_level": model_data.get("preview_nsfw_level", 0),
"base_model": checkpoint_data.get("base_model", ""), "base_model": model_data.get("base_model", ""),
"folder": folder, "folder": folder,
"sha256": checkpoint_data.get("sha256", ""), "sha256": model_data.get("sha256", ""),
"file_path": file_path.replace(os.sep, "/"), "file_path": file_path.replace(os.sep, "/"),
"file_size": checkpoint_data.get("size", 0), "file_size": model_data.get("size", 0),
"modified": checkpoint_data.get("modified", ""), "modified": model_data.get("modified", ""),
"tags": checkpoint_data.get("tags", []), "tags": model_data.get("tags", []),
"from_civitai": checkpoint_data.get("from_civitai", True), "from_civitai": model_data.get("from_civitai", True),
"usage_count": checkpoint_data.get("usage_count", 0), "usage_count": model_data.get("usage_count", 0),
"notes": checkpoint_data.get("notes", ""), "notes": model_data.get("notes", ""),
"sub_type": sub_type, "sub_type": sub_type,
"favorite": checkpoint_data.get("favorite", False), "favorite": model_data.get("favorite", False),
"exclude": bool(checkpoint_data.get("exclude", False)), "exclude": bool(model_data.get("exclude", False)),
"update_available": bool(checkpoint_data.get("update_available", False)), "update_available": bool(model_data.get("update_available", False)),
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)), "skip_metadata_refresh": bool(model_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True), "civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data), "auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": checkpoint_data.get("version_count"), "version_count": model_data.get("version_count"),
"hf_url": checkpoint_data.get("hf_url", ""), "hf_url": model_data.get("hf_url", ""),
} }
def find_duplicate_hashes(self) -> Dict: def find_duplicate_hashes(self) -> Dict[str, Any]:
"""Find Checkpoints with duplicate SHA256 hashes""" """Find Checkpoints with duplicate SHA256 hashes"""
return self.scanner._hash_index.get_duplicate_hashes() return self.scanner._hash_index.get_duplicate_hashes()
def find_duplicate_filenames(self) -> Dict: def find_duplicate_filenames(self) -> Dict[str, Any]:
"""Find Checkpoints with conflicting filenames""" """Find Checkpoints with conflicting filenames"""
return self.scanner._hash_index.get_duplicate_filenames() return self.scanner._hash_index.get_duplicate_filenames()
+41 -36
View File
@@ -1,8 +1,12 @@
# pyright: reportImportCycles=false
# Lazy (function-local) imports still count as static edges in basedpyright's
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
import json import json
import logging import logging
import asyncio import asyncio
from copy import deepcopy from copy import deepcopy
from typing import Optional, Dict, Tuple, List from typing import Any, Optional, Dict, Tuple, List, cast
from .model_metadata_provider import CivArchiveModelMetadataProvider, ModelMetadataProviderManager from .model_metadata_provider import CivArchiveModelMetadataProvider, ModelMetadataProviderManager
from .downloader import get_downloader from .downloader import get_downloader
from .errors import RateLimitError from .errors import RateLimitError
@@ -37,8 +41,8 @@ class CivArchiveClient:
async def _request_json( async def _request_json(
self, self,
path: str, path: str,
params: Optional[Dict[str, str]] = None params: Optional[Dict[str, Any]] = None
) -> Tuple[Optional[Dict], Optional[str]]: ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
"""Call CivArchive API and return JSON payload""" """Call CivArchive API and return JSON payload"""
success, payload = await self._make_request(path, params=params) success, payload = await self._make_request(path, params=params)
if not success: if not success:
@@ -52,12 +56,12 @@ class CivArchiveClient:
self, self,
path: str, path: str,
*, *,
params: Optional[Dict[str, str]] = None, params: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Dict | str]: ) -> Tuple[bool, Dict[str, Any] | str]:
"""Wrapper around downloader.make_request that surfaces rate limits.""" """Wrapper around downloader.make_request that surfaces rate limits."""
downloader = await get_downloader() downloader = await get_downloader()
kwargs: Dict[str, Dict[str, str]] = {} kwargs: Dict[str, Dict[str, Any]] = {}
if params: if params:
safe_params = {str(key): str(value) for key, value in params.items() if value is not None} safe_params = {str(key): str(value) for key, value in params.items() if value is not None}
if safe_params: if safe_params:
@@ -73,10 +77,11 @@ class CivArchiveClient:
if payload.provider is None: if payload.provider is None:
payload.provider = "civarchive_api" payload.provider = "civarchive_api"
raise payload raise payload
return success, payload # RateLimitError is always raised above, so the returned payload is a dict or str.
return success, cast(Dict[str, Any] | str, payload)
@staticmethod @staticmethod
def _normalize_payload(payload: Dict) -> Dict: def _normalize_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
"""Unwrap CivArchive responses that wrap content under a data key""" """Unwrap CivArchive responses that wrap content under a data key"""
if not isinstance(payload, dict): if not isinstance(payload, dict):
return {} return {}
@@ -86,12 +91,12 @@ class CivArchiveClient:
return payload return payload
@staticmethod @staticmethod
def _split_context(payload: Dict) -> Tuple[Dict, Dict, List[Dict]]: def _split_context(payload: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any], List[Dict[str, Any]]]:
"""Separate version payload from surrounding model context""" """Separate version payload from surrounding model context"""
data = CivArchiveClient._normalize_payload(payload) data = CivArchiveClient._normalize_payload(payload)
context: Dict = {} context: Dict[str, Any] = {}
fallback_files: List[Dict] = [] fallback_files: List[Dict[str, Any]] = []
version: Dict = {} version: Dict[str, Any] = {}
for key, value in data.items(): for key, value in data.items():
if key in {"version", "model"}: if key in {"version", "model"}:
@@ -115,7 +120,7 @@ class CivArchiveClient:
return context, version, fallback_files return context, version, fallback_files
@staticmethod @staticmethod
def _ensure_list(value) -> List: def _ensure_list(value: Any) -> List[Any]:
if isinstance(value, list): if isinstance(value, list):
return value return value
if value is None: if value is None:
@@ -123,7 +128,7 @@ class CivArchiveClient:
return [value] return [value]
@staticmethod @staticmethod
def _build_model_info(context: Dict) -> Dict: def _build_model_info(context: Dict[str, Any]) -> Dict[str, Any]:
tags = context.get("tags") tags = context.get("tags")
if not isinstance(tags, list): if not isinstance(tags, list):
tags = list(tags) if isinstance(tags, (set, tuple)) else ([] if tags is None else [tags]) tags = list(tags) if isinstance(tags, (set, tuple)) else ([] if tags is None else [tags])
@@ -136,7 +141,7 @@ class CivArchiveClient:
} }
@staticmethod @staticmethod
def _build_creator_info(context: Dict) -> Dict: def _build_creator_info(context: Dict[str, Any]) -> Dict[str, Any]:
username = context.get("creator_username") or context.get("username") or "" username = context.get("creator_username") or context.get("username") or ""
image = context.get("creator_image") or context.get("creator_avatar") or "" image = context.get("creator_image") or context.get("creator_avatar") or ""
creator: Dict[str, Optional[str]] = { creator: Dict[str, Optional[str]] = {
@@ -150,7 +155,7 @@ class CivArchiveClient:
return creator return creator
@staticmethod @staticmethod
def _transform_file_entry(file_data: Dict) -> Dict: def _transform_file_entry(file_data: Dict[str, Any]) -> Dict[str, Any]:
mirrors = file_data.get("mirrors") or [] mirrors = file_data.get("mirrors") or []
if not isinstance(mirrors, list): if not isinstance(mirrors, list):
mirrors = [mirrors] mirrors = [mirrors]
@@ -165,7 +170,7 @@ class CivArchiveClient:
if not name and available_mirror: if not name and available_mirror:
name = available_mirror.get("filename") name = available_mirror.get("filename")
transformed: Dict = { transformed: Dict[str, Any] = {
"id": file_data.get("id"), "id": file_data.get("id"),
"sizeKB": file_data.get("sizeKB"), "sizeKB": file_data.get("sizeKB"),
"name": name, "name": name,
@@ -216,23 +221,23 @@ class CivArchiveClient:
def _transform_files( def _transform_files(
self, self,
files: Optional[List[Dict]], files: Optional[List[Dict[str, Any]]],
fallback_files: Optional[List[Dict]] = None fallback_files: Optional[List[Dict[str, Any]]] = None
) -> List[Dict]: ) -> List[Dict[str, Any]]:
candidates: List[Dict] = [] candidates: List[Dict[str, Any]] = []
if isinstance(files, list) and files: if isinstance(files, list) and files:
candidates = files candidates = files
elif isinstance(fallback_files, list): elif isinstance(fallback_files, list):
candidates = fallback_files candidates = fallback_files
transformed_files: List[Dict] = [] transformed_files: List[Dict[str, Any]] = []
for file_data in candidates: for file_data in candidates:
if isinstance(file_data, dict): if isinstance(file_data, dict):
transformed_files.append(self._transform_file_entry(file_data)) transformed_files.append(self._transform_file_entry(file_data))
# Sort: .safetensors first, .ckpt second, others last # Sort: .safetensors first, .ckpt second, others last
# so the backend fallback (no file_params) prefers safetensors # so the backend fallback (no file_params) prefers safetensors
def _sort_key(f: Dict) -> int: def _sort_key(f: Dict[str, Any]) -> int:
fname = f.get("name") or "" fname = f.get("name") or ""
if isinstance(fname, str): if isinstance(fname, str):
lower = fname.lower() lower = fname.lower()
@@ -247,10 +252,10 @@ class CivArchiveClient:
def _transform_version( def _transform_version(
self, self,
context: Dict, context: Dict[str, Any],
version: Dict, version: Dict[str, Any],
fallback_files: Optional[List[Dict]] = None fallback_files: Optional[List[Dict[str, Any]]] = None
) -> Optional[Dict]: ) -> Optional[Dict[str, Any]]:
if not version: if not version:
return None return None
@@ -291,7 +296,7 @@ class CivArchiveClient:
return version_copy return version_copy
async def _resolve_version_from_files(self, payload: Dict) -> Optional[Dict]: async def _resolve_version_from_files(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Fallback to fetch version data when only file metadata is available""" """Fallback to fetch version data when only file metadata is available"""
data = self._normalize_payload(payload) data = self._normalize_payload(payload)
files = data.get("files") or payload.get("files") or [] files = data.get("files") or payload.get("files") or []
@@ -323,7 +328,7 @@ class CivArchiveClient:
return resolved return resolved
return None return None
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]: async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
"""Find model by SHA256 hash value using CivArchive API""" """Find model by SHA256 hash value using CivArchive API"""
try: try:
payload, error = await self._request_json(f"/sha256/{model_hash.lower()}") payload, error = await self._request_json(f"/sha256/{model_hash.lower()}")
@@ -332,12 +337,12 @@ class CivArchiveClient:
return None, "Model not found" return None, "Model not found"
return None, error return None, error
context, version_data, fallback_files = self._split_context(payload) context, version_data, fallback_files = self._split_context(cast(Dict[str, Any], payload))
transformed = self._transform_version(context, version_data, fallback_files) transformed = self._transform_version(context, version_data, fallback_files)
if transformed: if transformed:
return transformed, None return transformed, None
resolved = await self._resolve_version_from_files(payload) resolved = await self._resolve_version_from_files(cast(Dict[str, Any], payload))
if resolved: if resolved:
return resolved, None return resolved, None
@@ -350,7 +355,7 @@ class CivArchiveClient:
logger.error(f"Error fetching CivArchive model by hash {model_hash[:10]}: {e}") logger.error(f"Error fetching CivArchive model by hash {model_hash[:10]}: {e}")
return None, str(e) return None, str(e)
async def get_model_versions(self, model_id: str) -> Optional[Dict]: async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
"""Get all versions of a model using CivArchive API""" """Get all versions of a model using CivArchive API"""
try: try:
payload, error = await self._request_json(f"/models/{model_id}") payload, error = await self._request_json(f"/models/{model_id}")
@@ -364,7 +369,7 @@ class CivArchiveClient:
context, version_data, fallback_files = self._split_context(payload) context, version_data, fallback_files = self._split_context(payload)
versions_meta = data.get("versions") or [] versions_meta = data.get("versions") or []
transformed_versions: List[Dict] = [] transformed_versions: List[Dict[str, Any]] = []
for meta in versions_meta: for meta in versions_meta:
if not isinstance(meta, dict): if not isinstance(meta, dict):
continue continue
@@ -381,7 +386,7 @@ class CivArchiveClient:
if primary_version: if primary_version:
transformed_versions.insert(0, primary_version) transformed_versions.insert(0, primary_version)
ordered_versions: List[Dict] = [] ordered_versions: List[Dict[str, Any]] = []
seen_ids = set() seen_ids = set()
for version in transformed_versions: for version in transformed_versions:
version_id = version.get("id") version_id = version.get("id")
@@ -402,7 +407,7 @@ class CivArchiveClient:
logger.error(f"Error fetching CivArchive model versions for {model_id}: {e}") logger.error(f"Error fetching CivArchive model versions for {model_id}: {e}")
return None return None
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]: async def get_model_version(self, model_id: int | str | None = None, version_id: int | str | None = None) -> Optional[Dict[str, Any]]:
"""Get specific model version using CivArchive API """Get specific model version using CivArchive API
Args: Args:
@@ -459,7 +464,7 @@ class CivArchiveClient:
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {e}") logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {e}")
return None return None
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]: async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
""" Fetch model version metadata using a known bogus model lookup """ Fetch model version metadata using a known bogus model lookup
CivArchive lacks a direct version lookup API, this uses a workaround (which we handle in the main model request now) CivArchive lacks a direct version lookup API, this uses a workaround (which we handle in the main model request now)
+1 -1
View File
@@ -283,7 +283,7 @@ class CivitaiBaseModelService:
return None return None
if isinstance(result, str): if isinstance(result, str):
data = json.loads(result) data: Any = json.loads(result)
else: else:
data = result data = result
+49 -37
View File
@@ -1,10 +1,14 @@
# pyright: reportImportCycles=false
# Lazy (function-local) imports still count as static edges in basedpyright's
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
import asyncio import asyncio
import copy import copy
import logging import logging
import os import os
import time import time
from collections import OrderedDict from collections import OrderedDict
from typing import Any, Optional, Dict, Tuple, List, Sequence from typing import Any, Optional, Dict, Tuple, List, Sequence, cast
from .connectivity_guard import ( from .connectivity_guard import (
OFFLINE_FRIENDLY_MESSAGE, OFFLINE_FRIENDLY_MESSAGE,
is_expected_offline_error, is_expected_offline_error,
@@ -17,6 +21,7 @@ from .model_metadata_provider import (
from .downloader import get_downloader from .downloader import get_downloader
from .errors import RateLimitError, ResourceNotFoundError from .errors import RateLimitError, ResourceNotFoundError
from ..utils.civitai_utils import resolve_license_payload from ..utils.civitai_utils import resolve_license_payload
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -58,7 +63,7 @@ class CivitaiClient:
# Uses OrderedDict with LRU eviction at MAX_CACHE_ENTRIES to prevent # Uses OrderedDict with LRU eviction at MAX_CACHE_ENTRIES to prevent
# unbounded growth in long-running server processes. # unbounded growth in long-running server processes.
self._version_info_cache: OrderedDict[ self._version_info_cache: OrderedDict[
str, Tuple[Optional[Dict], Optional[str]] str, Tuple[Optional[Dict[str, Any]], Optional[str]]
] = OrderedDict() ] = OrderedDict()
self._MAX_CACHE_ENTRIES = 500 self._MAX_CACHE_ENTRIES = 500
@@ -72,7 +77,7 @@ class CivitaiClient:
*, *,
use_auth: bool = False, use_auth: bool = False,
**kwargs, **kwargs,
) -> Tuple[bool, Dict | str]: ) -> Tuple[bool, Dict[str, Any] | str]:
"""Wrapper around downloader.make_request that surfaces rate limits, """Wrapper around downloader.make_request that surfaces rate limits,
with retry for transient server errors (5xx, Cloudflare 524, network flakiness).""" with retry for transient server errors (5xx, Cloudflare 524, network flakiness)."""
@@ -86,7 +91,8 @@ class CivitaiClient:
**kwargs, **kwargs,
) )
if success: if success:
return True, result # RateLimitError is raised below; a successful result is dict or str.
return True, cast(Dict[str, Any] | str, result)
if isinstance(result, RateLimitError): if isinstance(result, RateLimitError):
if result.provider is None: if result.provider is None:
@@ -126,7 +132,7 @@ class CivitaiClient:
return False, "Unexpected error in _make_request" return False, "Unexpected error in _make_request"
@staticmethod @staticmethod
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None: def _remove_comfy_metadata(model_version: Optional[Dict[str, Any]]) -> None:
"""Remove Comfy-specific metadata from model version images.""" """Remove Comfy-specific metadata from model version images."""
if not isinstance(model_version, dict): if not isinstance(model_version, dict):
return return
@@ -173,7 +179,7 @@ class CivitaiClient:
async def get_model_by_hash( async def get_model_by_hash(
self, model_hash: str self, model_hash: str
) -> Tuple[Optional[Dict], Optional[str]]: ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
try: try:
success, version = await self._make_request( success, version = await self._make_request(
"GET", "GET",
@@ -220,7 +226,7 @@ class CivitaiClient:
# Ensure directory exists # Ensure directory exists
os.makedirs(os.path.dirname(save_path), exist_ok=True) os.makedirs(os.path.dirname(save_path), exist_ok=True)
with open(save_path, "wb") as f: with open(save_path, "wb") as f:
f.write(content) f.write(content if isinstance(content, bytes) else content.encode("utf-8"))
return True return True
return False return False
except Exception as e: except Exception as e:
@@ -275,7 +281,7 @@ class CivitaiClient:
return True return True
return False return False
async def get_model_versions(self, model_id: str) -> Optional[Dict]: async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
"""Get all versions of a model with local availability info""" """Get all versions of a model with local availability info"""
try: try:
success, result = await self._make_request( success, result = await self._make_request(
@@ -283,7 +289,7 @@ class CivitaiClient:
f"{self.base_url}/models/{model_id}", f"{self.base_url}/models/{model_id}",
use_auth=True, use_auth=True,
) )
if success: if success and isinstance(result, dict):
# Also return model type along with versions # Also return model type along with versions
return { return {
"modelVersions": result.get("modelVersions", []), "modelVersions": result.get("modelVersions", []),
@@ -317,7 +323,7 @@ class CivitaiClient:
async def get_model_versions_bulk( async def get_model_versions_bulk(
self, model_ids: Sequence[int] self, model_ids: Sequence[int]
) -> Optional[Dict[int, Dict]]: ) -> Optional[Dict[int, Dict[str, Any]]]:
"""Fetch model metadata for multiple ids using the batch API.""" """Fetch model metadata for multiple ids using the batch API."""
deduped: Dict[int, None] = {} deduped: Dict[int, None] = {}
@@ -347,13 +353,13 @@ class CivitaiClient:
if not isinstance(items, list): if not isinstance(items, list):
return {} return {}
payload: Dict[int, Dict] = {} payload: Dict[int, Dict[str, Any]] = {}
for item in items: for item in items:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
model_id = item.get("id") model_id = item.get("id")
try: try:
normalized_id = int(model_id) normalized_id = int(cast(Any, model_id))
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
payload[normalized_id] = { payload[normalized_id] = {
@@ -373,8 +379,8 @@ class CivitaiClient:
return None return None
async def get_model_version( async def get_model_version(
self, model_id: int = None, version_id: int = None self, model_id: int | None = None, version_id: int | None = None
) -> Optional[Dict]: ) -> Optional[Dict[str, Any]]:
"""Get specific model version with additional metadata.""" """Get specific model version with additional metadata."""
try: try:
if model_id is None and version_id is not None: if model_id is None and version_id is not None:
@@ -392,7 +398,7 @@ class CivitaiClient:
logger.error(f"Error fetching model version: {e}") logger.error(f"Error fetching model version: {e}")
return None return None
async def _get_version_by_id_only(self, version_id: int) -> Optional[Dict]: async def _get_version_by_id_only(self, version_id: int) -> Optional[Dict[str, Any]]:
version = await self._fetch_version_by_id(version_id) version = await self._fetch_version_by_id(version_id)
if version is None: if version is None:
return None return None
@@ -411,7 +417,7 @@ class CivitaiClient:
async def _get_version_with_model_id( async def _get_version_with_model_id(
self, model_id: int, version_id: Optional[int] self, model_id: int, version_id: Optional[int]
) -> Optional[Dict]: ) -> Optional[Dict[str, Any]]:
model_data = await self._fetch_model_data(model_id) model_data = await self._fetch_model_data(model_id)
if not model_data: if not model_data:
return None return None
@@ -464,20 +470,20 @@ class CivitaiClient:
self._remove_comfy_metadata(version) self._remove_comfy_metadata(version)
return version return version
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]: async def _fetch_model_data(self, model_id: int) -> Optional[Dict[str, Any]]:
success, data = await self._make_request( success, data = await self._make_request(
"GET", "GET",
f"{self.base_url}/models/{model_id}", f"{self.base_url}/models/{model_id}",
use_auth=True, use_auth=True,
) )
if success: if success and isinstance(data, dict):
return data return data
if is_expected_offline_error(data): if is_expected_offline_error(data):
return None return None
logger.warning(f"Failed to fetch model data for model {model_id}") logger.warning(f"Failed to fetch model data for model {model_id}")
return None return None
async def _fetch_version_by_id(self, version_id: Optional[int]) -> Optional[Dict]: async def _fetch_version_by_id(self, version_id: Optional[int]) -> Optional[Dict[str, Any]]:
if version_id is None: if version_id is None:
return None return None
@@ -486,7 +492,7 @@ class CivitaiClient:
f"{self.base_url}/model-versions/{version_id}", f"{self.base_url}/model-versions/{version_id}",
use_auth=True, use_auth=True,
) )
if success: if success and isinstance(version, dict):
return version return version
if is_expected_offline_error(version): if is_expected_offline_error(version):
return None return None
@@ -494,7 +500,7 @@ class CivitaiClient:
logger.warning(f"Failed to fetch version by id {version_id}") logger.warning(f"Failed to fetch version by id {version_id}")
return None return None
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict]: async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict[str, Any]]:
if not model_hash: if not model_hash:
return None return None
@@ -503,7 +509,7 @@ class CivitaiClient:
f"{self.base_url}/model-versions/by-hash/{model_hash}", f"{self.base_url}/model-versions/by-hash/{model_hash}",
use_auth=True, use_auth=True,
) )
if success: if success and isinstance(version, dict):
return version return version
if is_expected_offline_error(version): if is_expected_offline_error(version):
return None return None
@@ -512,8 +518,8 @@ class CivitaiClient:
return None return None
def _select_target_version( def _select_target_version(
self, model_data: Dict, model_id: int, version_id: Optional[int] self, model_data: Dict[str, Any], model_id: int, version_id: Optional[int]
) -> Optional[Dict]: ) -> Optional[Dict[str, Any]]:
model_versions = model_data.get("modelVersions", []) model_versions = model_data.get("modelVersions", [])
if not model_versions: if not model_versions:
logger.warning(f"No model versions found for model {model_id}") logger.warning(f"No model versions found for model {model_id}")
@@ -532,18 +538,24 @@ class CivitaiClient:
return model_versions[0] return model_versions[0]
def _extract_primary_model_hash(self, version_entry: Dict) -> Optional[str]: def _extract_primary_model_hash(self, version_entry: Dict[str, Any]) -> Optional[str]:
# Prefer the generic "Model" file (most reliable version identity);
# fall back to any other weights-type primary.
for file_info in version_entry.get("files", []): for file_info in version_entry.get("files", []):
if file_info.get("type") == "Model" and file_info.get("primary"): if file_info.get("type") == "Model" and file_info.get("primary"):
hashes = file_info.get("hashes", {}) model_hash = (file_info.get("hashes", {}) or {}).get("SHA256")
model_hash = hashes.get("SHA256") if model_hash:
return model_hash
for file_info in version_entry.get("files", []):
if file_info.get("type") in MODEL_WEIGHT_FILE_TYPES and file_info.get("primary"):
model_hash = (file_info.get("hashes", {}) or {}).get("SHA256")
if model_hash: if model_hash:
return model_hash return model_hash
return None return None
def _build_version_from_model_data( def _build_version_from_model_data(
self, version_entry: Dict, model_id: int, model_data: Dict self, version_entry: Dict[str, Any], model_id: int, model_data: Dict[str, Any]
) -> Dict: ) -> Dict[str, Any]:
version = copy.deepcopy(version_entry) version = copy.deepcopy(version_entry)
version.pop("index", None) version.pop("index", None)
version["modelId"] = model_id version["modelId"] = model_id
@@ -555,7 +567,7 @@ class CivitaiClient:
} }
return version return version
def _enrich_version_with_model_data(self, version: Dict, model_data: Dict) -> None: def _enrich_version_with_model_data(self, version: Dict[str, Any], model_data: Dict[str, Any]) -> None:
model_info = version.get("model") model_info = version.get("model")
if not isinstance(model_info, dict): if not isinstance(model_info, dict):
model_info = {} model_info = {}
@@ -571,7 +583,7 @@ class CivitaiClient:
async def get_model_version_info( async def get_model_version_info(
self, version_id: str self, version_id: str
) -> Tuple[Optional[Dict], Optional[str]]: ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
"""Fetch model version metadata from Civitai """Fetch model version metadata from Civitai
Args: Args:
@@ -596,7 +608,7 @@ class CivitaiClient:
logger.debug("Resolving Civitai model version info: %s", url) logger.debug("Resolving Civitai model version info: %s", url)
success, result = await self._make_request("GET", url, use_auth=True) success, result = await self._make_request("GET", url, use_auth=True)
if success: if success and isinstance(result, dict):
logger.debug("Successfully fetched model version info for: %s", version_id) logger.debug("Successfully fetched model version info for: %s", version_id)
self._remove_comfy_metadata(result) self._remove_comfy_metadata(result)
self._version_info_cache[version_id] = (result, None) self._version_info_cache[version_id] = (result, None)
@@ -626,7 +638,7 @@ class CivitaiClient:
async def get_image_info( async def get_image_info(
self, image_id: str, source_url: str | None = None self, image_id: str, source_url: str | None = None
) -> Optional[Dict]: ) -> Optional[Dict[str, Any]]:
"""Fetch image information from Civitai API """Fetch image information from Civitai API
Args: Args:
@@ -659,7 +671,7 @@ class CivitaiClient:
) )
return None return None
if result and "items" in result and isinstance(result["items"], list): if isinstance(result, dict) and "items" in result and isinstance(result["items"], list):
items = result["items"] items = result["items"]
for item in items: for item in items:
@@ -699,7 +711,7 @@ class CivitaiClient:
async def get_model_versions_by_hashes( async def get_model_versions_by_hashes(
self, hashes: List[str] self, hashes: List[str]
) -> Optional[List[Dict]]: ) -> Optional[List[Dict[str, Any]]]:
"""Fetch full version details for up to 100 SHA256 hashes via the batch endpoint. """Fetch full version details for up to 100 SHA256 hashes via the batch endpoint.
Uses POST /api/v1/model-versions/by-hash which returns full version Uses POST /api/v1/model-versions/by-hash which returns full version
@@ -716,7 +728,7 @@ class CivitaiClient:
return [] return []
BATCH_SIZE = 100 BATCH_SIZE = 100
all_versions: List[Dict] = [] all_versions: List[Dict[str, Any]] = []
for start in range(0, len(hashes), BATCH_SIZE): for start in range(0, len(hashes), BATCH_SIZE):
batch = hashes[start : start + BATCH_SIZE] batch = hashes[start : start + BATCH_SIZE]
@@ -736,7 +748,7 @@ class CivitaiClient:
continue continue
if isinstance(result, list): if isinstance(result, list):
all_versions.extend(result) all_versions.extend(cast(Any, result))
else: else:
logger.debug( logger.debug(
"Unexpected by-hash response type: %s", type(result) "Unexpected by-hash response type: %s", type(result)
+5 -2
View File
@@ -18,7 +18,7 @@ class DownloadCoordinator:
self, self,
*, *,
ws_manager, ws_manager,
download_manager_factory: Callable[[], Awaitable], download_manager_factory: Callable[[], Awaitable[Any]],
) -> None: ) -> None:
self._ws_manager = ws_manager self._ws_manager = ws_manager
self._download_manager_factory = download_manager_factory self._download_manager_factory = download_manager_factory
@@ -83,10 +83,13 @@ class DownloadCoordinator:
save_dir=payload.get("model_root"), save_dir=payload.get("model_root"),
relative_path=payload.get("relative_path", ""), relative_path=payload.get("relative_path", ""),
use_default_paths=payload.get("use_default_paths", False), use_default_paths=payload.get("use_default_paths", False),
use_save_dir_as_root=payload.get("use_save_dir_as_root", False),
progress_callback=progress_callback, progress_callback=progress_callback,
download_id=download_id, download_id=download_id,
source=payload.get("source"), source=payload.get("source"),
file_params=payload.get("file_params"), # Normalize falsy file_params (e.g. {}) to None so download gates
# treat it as "no explicit file selection" (#1058).
file_params=payload.get("file_params") or None,
) )
result["download_id"] = download_id result["download_id"] = download_id
File diff suppressed because it is too large Load Diff
+74 -25
View File
@@ -12,6 +12,15 @@ from ..utils.cache_paths import get_cache_base_dir
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# SQL fragment extracting the CivitAI file id from the JSON ``file_params``
# column (#1058). ``json_valid`` guards against NULL and legacy/unparseable
# values, yielding NULL for rows without a file identity; NULL keys group
# together so such rows keep the old version-level dedup behavior.
_FILE_ID_SQL = (
"CASE WHEN json_valid(file_params) "
"THEN json_extract(file_params, '$.id') END"
)
def _resolve_database_path() -> str: def _resolve_database_path() -> str:
base_dir = get_cache_base_dir(create=True) base_dir = get_cache_base_dir(create=True)
@@ -64,6 +73,7 @@ class DownloadQueueService:
model_name TEXT NOT NULL DEFAULT '', model_name TEXT NOT NULL DEFAULT '',
version_name TEXT DEFAULT '', version_name TEXT DEFAULT '',
thumbnail_url TEXT DEFAULT '', thumbnail_url TEXT DEFAULT '',
file_params TEXT,
status TEXT NOT NULL, status TEXT NOT NULL,
error TEXT, error TEXT,
file_path TEXT, file_path TEXT,
@@ -120,6 +130,18 @@ class DownloadQueueService:
with self._connect() as conn: with self._connect() as conn:
conn.executescript(self._SCHEMA_TABLES) conn.executescript(self._SCHEMA_TABLES)
# Databases created by older versions lack
# download_history.file_params; add it so retry-from-history can
# restore the originally selected file (#1058).
history_columns = {
row["name"]
for row in conn.execute("PRAGMA table_info(download_history)")
}
if "file_params" not in history_columns:
conn.execute(
"ALTER TABLE download_history ADD COLUMN file_params TEXT"
)
# Creating the unique index on download_history.download_id can # Creating the unique index on download_history.download_id can
# fail if pre-existing rows have duplicate values (e.g. from a # fail if pre-existing rows have duplicate values (e.g. from a
# previous version that lacked the index). Deduplicate first so # previous version that lacked the index). Deduplicate first so
@@ -418,6 +440,12 @@ class DownloadQueueService:
return None return None
now = completed_at if completed_at is not None else time.time() now = completed_at if completed_at is not None else time.time()
# Guard against legacy databases whose download_queue table
# predates the file_params column.
queue_columns = set(row.keys())
file_params_json = (
row["file_params"] if "file_params" in queue_columns else None
)
conn.execute( conn.execute(
"DELETE FROM download_queue WHERE download_id = ?", "DELETE FROM download_queue WHERE download_id = ?",
(download_id,), (download_id,),
@@ -426,9 +454,9 @@ class DownloadQueueService:
""" """
INSERT OR IGNORE INTO download_history ( INSERT OR IGNORE INTO download_history (
download_id, model_id, model_version_id, model_name, download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, status, error, file_path, version_name, thumbnail_url, file_params, status, error,
bytes_downloaded, total_bytes, completed_at file_path, bytes_downloaded, total_bytes, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
row["download_id"], row["download_id"],
@@ -437,6 +465,7 @@ class DownloadQueueService:
row["model_name"], row["model_name"],
row["version_name"], row["version_name"],
row["thumbnail_url"], row["thumbnail_url"],
file_params_json,
status, status,
error, error,
file_path, file_path,
@@ -503,6 +532,7 @@ class DownloadQueueService:
bytes_downloaded: int = 0, bytes_downloaded: int = 0,
total_bytes: Optional[int] = None, total_bytes: Optional[int] = None,
is_already_exists: int = 0, is_already_exists: int = 0,
file_params: Optional[dict[str, Any]] = None,
) -> int: ) -> int:
"""Insert a record into the download history. """Insert a record into the download history.
@@ -510,6 +540,7 @@ class DownloadQueueService:
inserted row. inserted row.
""" """
now = time.time() now = time.time()
file_params_json = json.dumps(file_params) if file_params is not None else None
async with self._lock: async with self._lock:
conn = self._get_conn() conn = self._get_conn()
@@ -517,9 +548,10 @@ class DownloadQueueService:
""" """
INSERT INTO download_history ( INSERT INTO download_history (
download_id, model_id, model_version_id, model_name, download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, status, error, file_path, version_name, thumbnail_url, file_params, status, error,
bytes_downloaded, total_bytes, completed_at, is_already_exists file_path, bytes_downloaded, total_bytes, completed_at,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) is_already_exists
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
download_id, download_id,
@@ -528,6 +560,7 @@ class DownloadQueueService:
model_name, model_name,
version_name, version_name,
thumbnail_url, thumbnail_url,
file_params_json,
status, status,
error, error,
file_path, file_path,
@@ -702,7 +735,7 @@ class DownloadQueueService:
download_id, model_id, model_version_id, model_name, download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, source, file_params, version_name, thumbnail_url, source, file_params,
status, priority, added_at status, priority, added_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
""", """,
( (
new_id, new_id,
@@ -712,6 +745,7 @@ class DownloadQueueService:
row["version_name"], row["version_name"],
row["thumbnail_url"], row["thumbnail_url"],
"retry", "retry",
row["file_params"],
now, now,
), ),
) )
@@ -755,7 +789,7 @@ class DownloadQueueService:
download_id, model_id, model_version_id, model_name, download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, source, file_params, version_name, thumbnail_url, source, file_params,
status, priority, added_at status, priority, added_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
""", """,
( (
new_id, new_id,
@@ -765,6 +799,7 @@ class DownloadQueueService:
row["version_name"], row["version_name"],
row["thumbnail_url"], row["thumbnail_url"],
"retry", "retry",
row["file_params"],
now, now,
), ),
) )
@@ -840,33 +875,44 @@ class DownloadQueueService:
async with self._lock: async with self._lock:
conn = self._get_conn() conn = self._get_conn()
# 1. History: for each (model_id, model_version_id, status) triplet # 1. History: for each (model_id, model_version_id, file_id,
# keep only the row with the highest id (most recently inserted). # status) group keep only the row with the highest id (most
conn.execute(""" # recently inserted). file_id comes from file_params (#1058)
# so distinct files of the same version never collapse.
conn.execute(f"""
DELETE FROM download_history DELETE FROM download_history
WHERE id NOT IN ( WHERE id NOT IN (
SELECT MAX(id) SELECT MAX(id)
FROM download_history FROM download_history
GROUP BY model_id, model_version_id, status GROUP BY model_id, model_version_id, status,
{_FILE_ID_SQL}
) )
""") """)
result["removed_history"] = conn.execute( result["removed_history"] = conn.execute(
"SELECT changes()" "SELECT changes()"
).fetchone()[0] ).fetchone()[0]
# 2. Cross-status dedup: for each (model_id, model_version_id), # 2. Cross-status dedup: for each (model_id, model_version_id,
# keep only the entry with the highest-priority terminal status. # file_id), keep only the entry with the highest-priority
# terminal status.
# Priority: completed (3) > failed (2) > canceled (1). # Priority: completed (3) > failed (2) > canceled (1).
# This prevents the same model version from having both a # This prevents the same file of a model version from having
# 'failed' and a 'canceled' entry (or a 'completed' alongside # both a 'failed' and a 'canceled' entry (or a 'completed'
# either) after the bug-created duplicates are removed. # alongside either) after the bug-created duplicates are
conn.execute(""" # removed. ``IS`` matches NULL file ids against each other so
# rows without file identity keep the old behavior.
conn.execute(f"""
DELETE FROM download_history DELETE FROM download_history
WHERE id NOT IN ( WHERE id NOT IN (
SELECT dh.id SELECT dh.id
FROM download_history dh FROM (
SELECT id, model_id, model_version_id, status,
{_FILE_ID_SQL} AS file_id
FROM download_history
) dh
INNER JOIN ( INNER JOIN (
SELECT model_id, model_version_id, SELECT model_id, model_version_id,
{_FILE_ID_SQL} AS file_id,
MAX(CASE status MAX(CASE status
WHEN 'completed' THEN 3 WHEN 'completed' THEN 3
WHEN 'failed' THEN 2 WHEN 'failed' THEN 2
@@ -874,17 +920,18 @@ class DownloadQueueService:
ELSE 0 ELSE 0
END) AS best_prio END) AS best_prio
FROM download_history FROM download_history
GROUP BY model_id, model_version_id GROUP BY model_id, model_version_id, {_FILE_ID_SQL}
) best ) best
ON dh.model_id = best.model_id ON dh.model_id = best.model_id
AND dh.model_version_id = best.model_version_id AND dh.model_version_id = best.model_version_id
AND dh.file_id IS best.file_id
AND CASE dh.status AND CASE dh.status
WHEN 'completed' THEN 3 WHEN 'completed' THEN 3
WHEN 'failed' THEN 2 WHEN 'failed' THEN 2
WHEN 'canceled' THEN 1 WHEN 'canceled' THEN 1
ELSE 0 ELSE 0
END = best.best_prio END = best.best_prio
GROUP BY dh.model_id, dh.model_version_id GROUP BY dh.model_id, dh.model_version_id, dh.file_id
HAVING dh.id = MAX(dh.id) HAVING dh.id = MAX(dh.id)
) )
""") """)
@@ -892,15 +939,17 @@ class DownloadQueueService:
"SELECT changes()" "SELECT changes()"
).fetchone()[0] ).fetchone()[0]
# 3. Queue: for each (model_id, model_version_id) keep only the # 3. Queue: for each (model_id, model_version_id, file_id) keep
# row with the latest added_at (most recently enqueued). # only the row with the latest added_at (most recently
conn.execute(""" # enqueued). file_id comes from file_params (#1058) so
# distinct files of the same version never collapse.
conn.execute(f"""
DELETE FROM download_queue DELETE FROM download_queue
WHERE rowid NOT IN ( WHERE rowid NOT IN (
SELECT MAX(rowid) SELECT MAX(rowid)
FROM download_queue FROM download_queue
WHERE status IN ('queued', 'downloading', 'paused', 'waiting') WHERE status IN ('queued', 'downloading', 'paused', 'waiting')
GROUP BY model_id, model_version_id GROUP BY model_id, model_version_id, {_FILE_ID_SQL}
) )
AND status IN ('queued', 'downloading', 'paused', 'waiting') AND status IN ('queued', 'downloading', 'paused', 'waiting')
""") """)
@@ -1,3 +1,7 @@
# pyright: reportImportCycles=false
# Lazy (function-local) imports still count as static edges in basedpyright's
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -58,6 +62,14 @@ class DownloadedVersionHistoryService:
); );
CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model
ON downloaded_model_versions(model_type, model_id); ON downloaded_model_versions(model_type, model_id);
CREATE TABLE IF NOT EXISTS downloaded_version_files (
model_type TEXT NOT NULL,
version_id INTEGER NOT NULL,
file_id INTEGER NOT NULL,
file_name TEXT,
downloaded_at REAL NOT NULL,
PRIMARY KEY (model_type, version_id, file_id)
);
""" """
def __init__(self, db_path: str | None = None, *, settings_manager=None) -> None: def __init__(self, db_path: str | None = None, *, settings_manager=None) -> None:
@@ -127,10 +139,13 @@ class DownloadedVersionHistoryService:
source: str = "manual", source: str = "manual",
file_path: str | None = None, file_path: str | None = None,
library_name: str | None = None, library_name: str | None = None,
file_id: int | None = None,
file_name: str | None = None,
) -> None: ) -> None:
normalized_type = _normalize_model_type(model_type) normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id) normalized_version_id = _normalize_int(version_id)
normalized_model_id = _normalize_int(model_id) normalized_model_id = _normalize_int(model_id)
normalized_file_id = _normalize_int(file_id)
if normalized_type is None or normalized_version_id is None: if normalized_type is None or normalized_version_id is None:
return return
@@ -164,6 +179,25 @@ class DownloadedVersionHistoryService:
active_library_name, active_library_name,
), ),
) )
if normalized_file_id is not None:
# Per-file history for multi-file versions (#1058)
conn.execute(
"""
INSERT INTO downloaded_version_files (
model_type, version_id, file_id, file_name, downloaded_at
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(model_type, version_id, file_id) DO UPDATE SET
file_name = COALESCE(excluded.file_name, downloaded_version_files.file_name),
downloaded_at = excluded.downloaded_at
""",
(
normalized_type,
normalized_version_id,
normalized_file_id,
file_name,
timestamp,
),
)
conn.commit() conn.commit()
async def mark_downloaded_bulk( async def mark_downloaded_bulk(
@@ -251,8 +285,63 @@ class DownloadedVersionHistoryService:
self._get_active_library_name(), self._get_active_library_name(),
), ),
) )
# Whole-version deletion also clears the per-file records (#1058)
conn.execute(
"""
DELETE FROM downloaded_version_files
WHERE model_type = ? AND version_id = ?
""",
(normalized_type, normalized_version_id),
)
conn.commit() conn.commit()
async def mark_file_deleted(
self, model_type: str, version_id: int, file_id: int
) -> None:
"""Drop a single file record of a version, keeping siblings (#1058)."""
normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id)
normalized_file_id = _normalize_int(file_id)
if (
normalized_type is None
or normalized_version_id is None
or normalized_file_id is None
):
return
async with self._lock:
conn = self._get_conn()
conn.execute(
"""
DELETE FROM downloaded_version_files
WHERE model_type = ? AND version_id = ? AND file_id = ?
""",
(normalized_type, normalized_version_id, normalized_file_id),
)
conn.commit()
async def get_downloaded_file_ids(
self, model_type: str, version_id: int
) -> list[int]:
"""Return the CivitAI file ids recorded as downloaded for a version."""
normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id)
if normalized_type is None or normalized_version_id is None:
return []
async with self._lock:
conn = self._get_conn()
rows = conn.execute(
"""
SELECT file_id
FROM downloaded_version_files
WHERE model_type = ? AND version_id = ?
ORDER BY file_id ASC
""",
(normalized_type, normalized_version_id),
).fetchall()
return [int(row["file_id"]) for row in rows]
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool: async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
normalized_type = _normalize_model_type(model_type) normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id) normalized_version_id = _normalize_int(version_id)
+13 -8
View File
@@ -1,3 +1,7 @@
# pyright: reportImportCycles=false
# Lazy (function-local) imports still count as static edges in basedpyright's
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
""" """
Unified download manager for all HTTP/HTTPS downloads in the application. Unified download manager for all HTTP/HTTPS downloads in the application.
@@ -20,7 +24,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
from urllib.parse import urlparse from urllib.parse import urlparse
from typing import Optional, Dict, Tuple, Callable, Union, Awaitable from typing import Optional, Dict, Tuple, Callable, Union, Awaitable, Any, cast
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from .connectivity_guard import ( from .connectivity_guard import (
OFFLINE_COOLDOWN_ERROR, OFFLINE_COOLDOWN_ERROR,
@@ -204,6 +208,7 @@ class Downloader:
# Double check after acquiring lock # Double check after acquiring lock
if self._session is None or self._should_refresh_session(): if self._session is None or self._should_refresh_session():
await self._create_session() await self._create_session()
assert self._session is not None
return self._session return self._session
@property @property
@@ -231,7 +236,7 @@ class Downloader:
) )
try: try:
timeout_value = float(raw_value) timeout_value = float(cast(Any, raw_value))
except (TypeError, ValueError): except (TypeError, ValueError):
timeout_value = default_timeout timeout_value = default_timeout
@@ -243,7 +248,7 @@ class Downloader:
raw_value = os.environ.get("COMFYUI_DOWNLOAD_MAX_RETRIES") raw_value = os.environ.get("COMFYUI_DOWNLOAD_MAX_RETRIES")
try: try:
retries = int(raw_value) retries = int(cast(Any, raw_value))
except (TypeError, ValueError): except (TypeError, ValueError):
retries = default_retries retries = default_retries
@@ -320,7 +325,7 @@ class Downloader:
# CA coverage across different Python environments (especially # CA coverage across different Python environments (especially
# embedded/compatibility Python builds). # embedded/compatibility Python builds).
try: try:
import certifi # type: ignore[import-untyped] import certifi # pyright: ignore[reportMissingTypeStubs]
ca_path = certifi.where() ca_path = certifi.where()
ssl_context = ssl.create_default_context(cafile=ca_path) ssl_context = ssl.create_default_context(cafile=ca_path)
@@ -330,7 +335,7 @@ class Downloader:
logger.debug("SSL: certifi unavailable; using system default CA bundle") logger.debug("SSL: certifi unavailable; using system default CA bundle")
# Optimize TCP connection parameters # Optimize TCP connection parameters
connector_kwargs = dict( connector_kwargs: Dict[str, Any] = dict(
ssl=ssl_context, ssl=ssl_context,
limit=8, # Concurrent connections limit=8, # Concurrent connections
ttl_dns_cache=300, # DNS cache timeout ttl_dns_cache=300, # DNS cache timeout
@@ -890,7 +895,7 @@ class Downloader:
use_auth: bool = False, use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None, custom_headers: Optional[Dict[str, str]] = None,
return_headers: bool = False, return_headers: bool = False,
) -> Tuple[bool, Union[bytes, str], Optional[Dict]]: ) -> Tuple[bool, Union[bytes, str], Optional[Dict[str, Any]]]:
""" """
Download a file to memory (for small files like preview images) Download a file to memory (for small files like preview images)
@@ -976,7 +981,7 @@ class Downloader:
url: str, url: str,
use_auth: bool = False, use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None, custom_headers: Optional[Dict[str, str]] = None,
) -> Tuple[bool, Union[Dict, str]]: ) -> Tuple[bool, Union[Dict[str, Any], str]]:
""" """
Get response headers without downloading the full content Get response headers without downloading the full content
@@ -1036,7 +1041,7 @@ class Downloader:
use_auth: bool = False, use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None, custom_headers: Optional[Dict[str, str]] = None,
**kwargs, **kwargs,
) -> Tuple[bool, Union[Dict, str]]: ) -> Tuple[bool, Union[Dict[str, Any], str, RateLimitError]]:
""" """
Make a generic HTTP request and return JSON response Make a generic HTTP request and return JSON response
+1 -1
View File
@@ -27,7 +27,7 @@ class EmbeddingScanner(ModelScanner):
roots.extend(config.embeddings_roots or []) roots.extend(config.embeddings_roots or [])
roots.extend(config.extra_embeddings_roots or []) roots.extend(config.extra_embeddings_roots or [])
# Remove duplicates while preserving order # Remove duplicates while preserving order
seen: set = set() seen: set[str] = set()
unique_roots: List[str] = [] unique_roots: List[str] = []
for root in roots: for root in roots:
if root and root not in seen: if root and root not in seen:
+28 -28
View File
@@ -1,6 +1,6 @@
import os import os
import logging import logging
from typing import Dict, Optional from typing import Any, Dict, Optional
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags from .auto_tag_service import extract_auto_tags
@@ -21,58 +21,58 @@ class EmbeddingService(BaseModelService):
""" """
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service) super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
async def format_response(self, embedding_data: Dict) -> Optional[Dict]: async def format_response(self, model_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Format Embedding data for API response. """Format Embedding data for API response.
Returns None when the entry is missing critical fields (corrupted cache Returns None when the entry is missing critical fields (corrupted cache
row), so the handler layer can filter it out. See issue #730. row), so the handler layer can filter it out. See issue #730.
""" """
# Guard against corrupted cache entries missing critical fields # Guard against corrupted cache entries missing critical fields
file_path = embedding_data.get("file_path") file_path = model_data.get("file_path")
if not file_path or not isinstance(file_path, str): if not file_path or not isinstance(file_path, str):
logger.warning( logger.warning(
"Skipping corrupted embedding entry (missing file_path): %s", "Skipping corrupted embedding entry (missing file_path): %s",
embedding_data.get("file_name", "<unknown>"), model_data.get("file_name", "<unknown>"),
) )
return None return None
# Get sub_type from cache entry (new canonical field) # Get sub_type from cache entry (new canonical field)
sub_type = embedding_data.get("sub_type", "embedding") sub_type = model_data.get("sub_type", "embedding")
file_name = embedding_data.get("file_name") or "" file_name = model_data.get("file_name") or ""
model_name = embedding_data.get("model_name") or file_name model_name = model_data.get("model_name") or file_name
folder = embedding_data.get("folder") or "" folder = model_data.get("folder") or ""
return { return {
"model_name": model_name, "model_name": model_name,
"file_name": file_name, "file_name": file_name,
"preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")), "preview_url": config.get_preview_static_url(model_data.get("preview_url", "")),
"preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0), "preview_nsfw_level": model_data.get("preview_nsfw_level", 0),
"base_model": embedding_data.get("base_model", ""), "base_model": model_data.get("base_model", ""),
"folder": folder, "folder": folder,
"sha256": embedding_data.get("sha256", ""), "sha256": model_data.get("sha256", ""),
"file_path": file_path.replace(os.sep, "/"), "file_path": file_path.replace(os.sep, "/"),
"file_size": embedding_data.get("size", 0), "file_size": model_data.get("size", 0),
"modified": embedding_data.get("modified", ""), "modified": model_data.get("modified", ""),
"tags": embedding_data.get("tags", []), "tags": model_data.get("tags", []),
"from_civitai": embedding_data.get("from_civitai", True), "from_civitai": model_data.get("from_civitai", True),
# "usage_count": embedding_data.get("usage_count", 0), # TODO: Enable when embedding usage tracking is implemented # "usage_count": model_data.get("usage_count", 0), # TODO: Enable when embedding usage tracking is implemented
"notes": embedding_data.get("notes", ""), "notes": model_data.get("notes", ""),
"sub_type": sub_type, "sub_type": sub_type,
"favorite": embedding_data.get("favorite", False), "favorite": model_data.get("favorite", False),
"exclude": bool(embedding_data.get("exclude", False)), "exclude": bool(model_data.get("exclude", False)),
"update_available": bool(embedding_data.get("update_available", False)), "update_available": bool(model_data.get("update_available", False)),
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)), "skip_metadata_refresh": bool(model_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True), "civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data), "auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": embedding_data.get("version_count"), "version_count": model_data.get("version_count"),
"hf_url": embedding_data.get("hf_url", ""), "hf_url": model_data.get("hf_url", ""),
} }
def find_duplicate_hashes(self) -> Dict: def find_duplicate_hashes(self) -> Dict[str, Any]:
"""Find Embeddings with duplicate SHA256 hashes""" """Find Embeddings with duplicate SHA256 hashes"""
return self.scanner._hash_index.get_duplicate_hashes() return self.scanner._hash_index.get_duplicate_hashes()
def find_duplicate_filenames(self) -> Dict: def find_duplicate_filenames(self) -> Dict[str, Any]:
"""Find Embeddings with conflicting filenames""" """Find Embeddings with conflicting filenames"""
return self.scanner._hash_index.get_duplicate_filenames() return self.scanner._hash_index.get_duplicate_filenames()
@@ -35,7 +35,7 @@ class CleanupResult:
def to_dict(self) -> Dict[str, object]: def to_dict(self) -> Dict[str, object]:
"""Convert the dataclass to a serialisable dictionary.""" """Convert the dataclass to a serialisable dictionary."""
data = { data: Dict[str, object] = {
"success": self.success, "success": self.success,
"checked_folders": self.checked_folders, "checked_folders": self.checked_folders,
"moved_empty_folders": self.moved_empty_folders, "moved_empty_folders": self.moved_empty_folders,
+5
View File
@@ -201,6 +201,11 @@ PROVIDER_PRESETS: Dict[str, Dict[str, Any]] = {
"api_base": "https://openrouter.ai/api/v1", "api_base": "https://openrouter.ai/api/v1",
"requires_key": True, "requires_key": True,
}, },
"google": {
"name": "Gemini",
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai",
"requires_key": True,
},
"opencode-go": { "opencode-go": {
"name": "OpenCode Go", "name": "OpenCode Go",
"api_base": "https://opencode.ai/zen/go/v1", "api_base": "https://opencode.ai/zen/go/v1",
+13 -3
View File
@@ -1,10 +1,12 @@
# pyright: reportImportCycles=false
# Lazy (function-local) imports still count as static edges in basedpyright's
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
import logging import logging
from typing import List from typing import List
from ..utils.models import LoraMetadata from ..utils.models import LoraMetadata
from ..config import config
from .model_scanner import ModelScanner from .model_scanner import ModelScanner
from .model_hash_index import ModelHashIndex # Changed from LoraHashIndex to ModelHashIndex
import sys import sys
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -17,6 +19,8 @@ class LoraScanner(ModelScanner):
file_extensions = {'.safetensors'} file_extensions = {'.safetensors'}
# Initialize parent class with ModelHashIndex # Initialize parent class with ModelHashIndex
from .model_hash_index import ModelHashIndex
super().__init__( super().__init__(
model_type="lora", model_type="lora",
model_class=LoraMetadata, model_class=LoraMetadata,
@@ -26,11 +30,13 @@ class LoraScanner(ModelScanner):
def get_model_roots(self) -> List[str]: def get_model_roots(self) -> List[str]:
"""Get lora root directories (including extra paths)""" """Get lora root directories (including extra paths)"""
from ..config import config
roots: List[str] = [] roots: List[str] = []
roots.extend(config.loras_roots or []) roots.extend(config.loras_roots or [])
roots.extend(config.extra_loras_roots or []) roots.extend(config.extra_loras_roots or [])
# Remove duplicates while preserving order # Remove duplicates while preserving order
seen: set = set() seen: set[str] = set()
unique_roots: List[str] = [] unique_roots: List[str] = []
for root in roots: for root in roots:
if root and root not in seen: if root and root not in seen:
@@ -68,8 +74,12 @@ class LoraScanner(ModelScanner):
test_hash = next(iter(self._hash_index._hash_to_path.keys())) test_hash = next(iter(self._hash_index._hash_to_path.keys()))
test_path = self._hash_index.get_path(test_hash) test_path = self._hash_index.get_path(test_hash)
logger.debug(f"\nTest lookup by hash: {test_hash[:8]}... -> {test_path}") logger.debug(f"\nTest lookup by hash: {test_hash[:8]}... -> {test_path}")
if test_path is None:
return
# Also test reverse lookup # Also test reverse lookup
test_hash_result = self._hash_index.get_hash(test_path) test_hash_result = self._hash_index.get_hash(test_path)
if test_hash_result is None:
return
logger.debug(f"Test reverse lookup: {test_path} -> {test_hash_result[:8]}...\n\n") logger.debug(f"Test reverse lookup: {test_path} -> {test_hash_result[:8]}...\n\n")

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