mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-17 03:03:20 -03:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e45c22d7a | |||
| 191c4e03cd | |||
| ab4154c57d | |||
| 28e93d12ff | |||
| 75e63c758b | |||
| 823f71f269 | |||
| 042dd4088d | |||
| eaa791a9eb | |||
| 2228627ff4 | |||
| 4c647ad9c8 | |||
| 8ca3e6c33f | |||
| dd6bdbf297 | |||
| b47dde87e4 | |||
| 99e65cccd8 | |||
| 3bdacb8f46 | |||
| b4f9c224d3 | |||
| 5ec0399c81 | |||
| b464fdc333 | |||
| 53825500db | |||
| f2ac790752 | |||
| 0d8805cdee | |||
| 656e24ac9b | |||
| 6718b37403 | |||
| c9e5e784fc | |||
| f92f958682 | |||
| f63fab0676 | |||
| cfc4903c0c | |||
| a527a847fe | |||
| 91b0bf8933 | |||
| 66d1c96783 | |||
| 986128076e | |||
| 1de0a53241 | |||
| 0ec7eaf606 | |||
| d9fcb0e92b | |||
| f49b4ba4db | |||
| 84e708328b | |||
| 125bed3f09 | |||
| 077e70169d | |||
| e6dc169a05 | |||
| f34c02756d | |||
| 1e4c315481 | |||
| a8283a0d00 | |||
| 55896669fc | |||
| e341e0b9d2 | |||
| e6538c83bb | |||
| 92e1285ea5 | |||
| 2aabd1d90e | |||
| 7b8b778f83 | |||
| 7c8dc57d55 | |||
| fe95fae5f2 | |||
| ce8a95abf7 | |||
| c8e7e543d6 | |||
| a9dbb15ffa | |||
| cf64043f7d | |||
| ccaff92c18 | |||
| 585b5c922a | |||
| ea80c2224c | |||
| 8b0f56c1a6 | |||
| 8022d12f03 | |||
| 3939f7f91b | |||
| aebf2e37dd | |||
| f53f859a71 | |||
| d916375abe | |||
| 57983df4bd | |||
| c68d7559a0 | |||
| 9a8f5bf2d6 | |||
| a8d742b031 | |||
| c27e4d1bfc | |||
| d15a8aa9a2 | |||
| 74a7d12ca4 | |||
| 2f94a9773e | |||
| 37bdfa21ea | |||
| f0bf2728c9 | |||
| dc715aa273 | |||
| 7ee2361e87 | |||
| e04c22f83f | |||
| 681cc13e90 | |||
| 090e0297d4 | |||
| 6f71335be4 | |||
| 7f51812c1e | |||
| a9dc4d7b9d | |||
| 5d50ddb5d4 | |||
| f86198d234 |
@@ -102,6 +102,7 @@ npm run test:coverage # Generate coverage report
|
|||||||
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)`
|
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)`
|
||||||
- Event handlers via `addEventListener` or widget callbacks
|
- Event handlers via `addEventListener` or widget callbacks
|
||||||
- Shared utilities: `web/comfyui/utils.js`
|
- Shared utilities: `web/comfyui/utils.js`
|
||||||
|
- Dual-mode rendering patterns (canvas vs Vue): see `docs/comfyui-dual-mode-widgets.md`
|
||||||
|
|
||||||
### Vue Composables Pattern
|
### Vue Composables Pattern
|
||||||
|
|
||||||
@@ -136,7 +137,13 @@ npm run test:coverage # Generate coverage report
|
|||||||
- Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json)
|
- Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json)
|
||||||
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
|
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
|
||||||
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
|
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
|
||||||
- Symlinks require normalized paths
|
- Symlinks require normalized paths.
|
||||||
|
**Business paths vs real paths**: All stored paths and operation routing use the
|
||||||
|
original paths as they appear under configured model roots — symlinks are NOT
|
||||||
|
resolved. `os.path.realpath` is only for scanner dedup and the symlink cache.
|
||||||
|
Any path passed to `os.remove`/`os.rename`/`shutil.move` or validated by a
|
||||||
|
containment check MUST use the business path (i.e. `os.path.abspath`, not
|
||||||
|
`realpath`).
|
||||||
|
|
||||||
## Git / Commit Messages
|
## Git / Commit Messages
|
||||||
|
|
||||||
|
|||||||
+15
@@ -16,6 +16,9 @@ try: # pragma: no cover - import fallback for pytest collection
|
|||||||
from .py.nodes.lora_randomizer import LoraRandomizerLM
|
from .py.nodes.lora_randomizer import LoraRandomizerLM
|
||||||
from .py.nodes.lora_cycler import LoraCyclerLM
|
from .py.nodes.lora_cycler import LoraCyclerLM
|
||||||
from .py.nodes.lora_info import LoraInfoLM
|
from .py.nodes.lora_info import LoraInfoLM
|
||||||
|
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
|
||||||
|
from .py.nodes.create_hook_lora import CreateHookLoraLM
|
||||||
|
from .py.nodes.metadata_overwrite import MetadataOverwriteLM
|
||||||
from .py.metadata_collector import init as init_metadata_collector
|
from .py.metadata_collector import init as init_metadata_collector
|
||||||
except (
|
except (
|
||||||
ImportError
|
ImportError
|
||||||
@@ -58,6 +61,15 @@ except (
|
|||||||
).LoraRandomizerLM
|
).LoraRandomizerLM
|
||||||
LoraCyclerLM = importlib.import_module("py.nodes.lora_cycler").LoraCyclerLM
|
LoraCyclerLM = importlib.import_module("py.nodes.lora_cycler").LoraCyclerLM
|
||||||
LoraInfoLM = importlib.import_module("py.nodes.lora_info").LoraInfoLM
|
LoraInfoLM = importlib.import_module("py.nodes.lora_info").LoraInfoLM
|
||||||
|
LoraSyntaxToPath = importlib.import_module(
|
||||||
|
"py.nodes.lora_syntax_to_path"
|
||||||
|
).LoraSyntaxToPath
|
||||||
|
CreateHookLoraLM = importlib.import_module(
|
||||||
|
"py.nodes.create_hook_lora"
|
||||||
|
).CreateHookLoraLM
|
||||||
|
MetadataOverwriteLM = importlib.import_module(
|
||||||
|
"py.nodes.metadata_overwrite"
|
||||||
|
).MetadataOverwriteLM
|
||||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
@@ -78,6 +90,9 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
LoraRandomizerLM.NAME: LoraRandomizerLM,
|
LoraRandomizerLM.NAME: LoraRandomizerLM,
|
||||||
LoraCyclerLM.NAME: LoraCyclerLM,
|
LoraCyclerLM.NAME: LoraCyclerLM,
|
||||||
LoraInfoLM.NAME: LoraInfoLM,
|
LoraInfoLM.NAME: LoraInfoLM,
|
||||||
|
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
|
||||||
|
CreateHookLoraLM.NAME: CreateHookLoraLM,
|
||||||
|
MetadataOverwriteLM.NAME: MetadataOverwriteLM,
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web/comfyui"
|
WEB_DIRECTORY = "./web/comfyui"
|
||||||
|
|||||||
+374
-339
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
|||||||
|
# ComfyUI Dual-Mode Widget Rendering
|
||||||
|
|
||||||
|
ComfyUI custom node widgets render in one of two modes. Patterns that work in one often fail silently in the other. Test both.
|
||||||
|
|
||||||
|
## Mode Detection
|
||||||
|
|
||||||
|
```js
|
||||||
|
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode
|
||||||
|
```
|
||||||
|
|
||||||
|
In Vue SFCs, `window.LiteGraph` is unavailable — pass as a prop from `main.ts`.
|
||||||
|
|
||||||
|
## Canvas Mode Layout
|
||||||
|
|
||||||
|
Uses `computeLayoutSize()` + `distributeSpace()` to allocate widget height within the node. Widgets with `computeLayoutSize` participate in space distribution; those with `computeSize` have fixed height.
|
||||||
|
|
||||||
|
- `getMinHeight()` in `addDOMWidget` options → minimum widget height
|
||||||
|
- `widget.computeLayoutSize()` → `{ minHeight, minWidth, maxHeight? }`
|
||||||
|
- Avoid `getMaxHeight()` unless the widget genuinely needs a fixed cap (prevents user resize)
|
||||||
|
|
||||||
|
## Vue Mode Layout
|
||||||
|
|
||||||
|
Uses CSS Grid (`grid-template-rows`) + `ResizeObserver`. The ResizeObserver watches the widget's DOM and feeds back into grid row sizing. This creates a feedback loop: content grows → row resizes → more space for content → content reflows/grows → row resizes again.
|
||||||
|
|
||||||
|
### Height Containment
|
||||||
|
|
||||||
|
The fix: `contain: layout size` on the widget root. This tells the browser the element's intrinsic size is CSS-determined, not driven by descendant content. The ResizeObserver sees a stable size and the loop is broken.
|
||||||
|
|
||||||
|
```css
|
||||||
|
.widget-root.lm-vue-node {
|
||||||
|
height: 100%;
|
||||||
|
min-height: var(--comfy-widget-min-height, 200px);
|
||||||
|
contain: layout size;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing examples: `.lm-loras-container.lm-vue-node` and `.comfy-tags-container.lm-vue-node` in `web/comfyui/lm_styles.css`.
|
||||||
|
|
||||||
|
**Do NOT** fix height issues with `maxHeight`, `getMaxHeight()`, or inline `max-height` — these prevent the user from resizing the node.
|
||||||
|
|
||||||
|
## Scroll Wheel Isolation
|
||||||
|
|
||||||
|
Both modes need to distinguish "user wants to scroll widget content" from "user wants to zoom canvas".
|
||||||
|
|
||||||
|
**Canvas mode:** Add `@wheel` on widget root. Check `event.target.closest(selector)` for scrollable sub-areas. If scrollable → `event.stopPropagation()`. Otherwise → `app.canvas.processMouseWheel(event)`.
|
||||||
|
|
||||||
|
**Vue mode:** Add CSS class `lm-wheel-scrollable` to scrollable elements. The global capture-phase hook in `web/comfyui/utils.js` (`enableListWheelScroll`) detects wheel events on marked elements and manually scrolls them via `element.scrollTop`, consuming the event before canvas zoom sees it.
|
||||||
|
|
||||||
|
## DOM Structure
|
||||||
|
|
||||||
|
`main.ts` creates an outer `<div>` container, then `vueApp.mount(container)`. The Vue app renders its own root element inside.
|
||||||
|
|
||||||
|
- `container.id` / `container.style.*` → outer element
|
||||||
|
- Vue scoped `<style>` → `[data-v-hash]` applies only to Vue root
|
||||||
|
|
||||||
|
Classes needed by scoped Vue CSS must go on the Vue root element. Pass data as props and bind with `:class` rather than manipulating the DOM from `main.ts`.
|
||||||
|
|
||||||
|
## Serialization
|
||||||
|
|
||||||
|
For stateful widgets that need workflow persistence:
|
||||||
|
|
||||||
|
- `serialize: true` in `addDOMWidget` options
|
||||||
|
- `serializeValue()` → state snapshot (called on workflow save)
|
||||||
|
- `onSetValue(v)` → restore state (called on workflow load)
|
||||||
|
- Always handle missing keys in restored value for backward compatibility with old workflows
|
||||||
File diff suppressed because one or more lines are too long
+29
-3
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "Voreinstellungsname...",
|
"presetNamePlaceholder": "Voreinstellungsname...",
|
||||||
"baseModel": "Basis-Modell",
|
"baseModel": "Basis-Modell",
|
||||||
"baseModelSearchPlaceholder": "Basismodelle durchsuchen...",
|
"baseModelSearchPlaceholder": "Basismodelle durchsuchen...",
|
||||||
"modelTags": "Tags (Top 20)",
|
"modelTags": "Tags",
|
||||||
"modelTypes": "Modelltypen",
|
"modelTypes": "Modelltypen",
|
||||||
"license": "Lizenz",
|
"license": "Lizenz",
|
||||||
"noCreditRequired": "Kein Credit erforderlich",
|
"noCreditRequired": "Kein Credit erforderlich",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
|
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
|
||||||
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
|
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
|
||||||
"noTags": "Keine Tags",
|
"noTags": "Keine Tags",
|
||||||
|
"tagSearchPlaceholder": "Tags durchsuchen...",
|
||||||
|
"noTagMatches": "Keine Tags entsprechen der aktuellen Suche.",
|
||||||
"autoTags": "Auto-Tags",
|
"autoTags": "Auto-Tags",
|
||||||
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
||||||
"clearAll": "Alle Filter löschen",
|
"clearAll": "Alle Filter löschen",
|
||||||
@@ -640,7 +642,13 @@
|
|||||||
"preparing": "Download wird vorbereitet...",
|
"preparing": "Download wird vorbereitet...",
|
||||||
"connecting": "Verbindung zum Download-Server wird hergestellt...",
|
"connecting": "Verbindung zum Download-Server wird hergestellt...",
|
||||||
"completed": "Abgeschlossen",
|
"completed": "Abgeschlossen",
|
||||||
"downloadComplete": "Download erfolgreich abgeschlossen"
|
"downloadComplete": "Download erfolgreich abgeschlossen",
|
||||||
|
"enableCivarchiveApi": "CivArchive API als Metadaten-Anbieter aktivieren",
|
||||||
|
"enableCivarchiveApiHelp": "Wenn aktiviert, wird die CivArchive API als alternative Quelle für Modell-Metadaten verwendet (z. B. für von CivitAI gelöschte Modelle). Deaktivieren, um die Ratenbegrenzungen von CivArchive vollständig zu vermeiden.",
|
||||||
|
"providerOrder": "Reihenfolge der Metadaten-Anbieter",
|
||||||
|
"providerOrderHelp": "Die CivitAI API wird immer zuerst versucht. Wählen Sie die Reihenfolge der übrigen Anbieter bei der Metadatensuche.",
|
||||||
|
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||||
|
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||||
},
|
},
|
||||||
"proxySettings": {
|
"proxySettings": {
|
||||||
"enableProxy": "App-Proxy aktivieren",
|
"enableProxy": "App-Proxy aktivieren",
|
||||||
@@ -706,7 +714,9 @@
|
|||||||
"versionsCount": "Lokale Versionen",
|
"versionsCount": "Lokale Versionen",
|
||||||
"versionsCountDesc": "Meiste Versionen zuerst",
|
"versionsCountDesc": "Meiste Versionen zuerst",
|
||||||
"versionsCountAsc": "Wenigste Versionen zuerst",
|
"versionsCountAsc": "Wenigste Versionen zuerst",
|
||||||
"versionIdDesc": "Neueste Version zuerst"
|
"versionIdDesc": "Neueste Version zuerst",
|
||||||
|
"random": "Zufällig",
|
||||||
|
"randomAction": "Zufällig mischen"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Modelliste aktualisieren",
|
"title": "Modelliste aktualisieren",
|
||||||
@@ -1540,6 +1550,7 @@
|
|||||||
"empty": "Noch keine Versionshistorie für dieses Modell vorhanden.",
|
"empty": "Noch keine Versionshistorie für dieses Modell vorhanden.",
|
||||||
"error": "Versionen konnten nicht geladen werden.",
|
"error": "Versionen konnten nicht geladen werden.",
|
||||||
"missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.",
|
"missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.",
|
||||||
|
"hfGroupInfo": "Dies ist eine HuggingFace-Modellgruppe. Öffnen Sie die Bibliothek, um alle Versionen im Raster zu sehen.",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "Diese Version aus Ihrer Bibliothek löschen?"
|
"delete": "Diese Version aus Ihrer Bibliothek löschen?"
|
||||||
},
|
},
|
||||||
@@ -1743,6 +1754,12 @@
|
|||||||
"checkingMessage": "Bitte warten Sie, während wir nach der neuesten Version suchen.",
|
"checkingMessage": "Bitte warten Sie, während wir nach der neuesten Version suchen.",
|
||||||
"showNotifications": "Update-Benachrichtigungen anzeigen",
|
"showNotifications": "Update-Benachrichtigungen anzeigen",
|
||||||
"latestBadge": "Neueste",
|
"latestBadge": "Neueste",
|
||||||
|
"latestMain": "Main-Branch",
|
||||||
|
"channel": "Update-Kanal",
|
||||||
|
"channels": {
|
||||||
|
"release": "Release",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "Update wird vorbereitet...",
|
"preparing": "Update wird vorbereitet...",
|
||||||
"installing": "Update wird installiert...",
|
"installing": "Update wird installiert...",
|
||||||
@@ -1763,6 +1780,15 @@
|
|||||||
"warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.",
|
"warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.",
|
||||||
"enable": "Nightly Updates aktivieren"
|
"enable": "Nightly Updates aktivieren"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "Zu Nightly-Kanal wechseln",
|
||||||
|
"nightlyMessage": "Der Wechsel zu Nightly initialisiert ein Git-Repository und verfolgt die neuesten Commits des main-Branches. Updates sind häufiger, können aber instabil sein. Sie können jederzeit zu Release zurückwechseln.",
|
||||||
|
"releaseTitle": "Zu Release-Kanal wechseln",
|
||||||
|
"releaseMessage": "Der Wechsel zu Release checkt den neuesten stabilen Versions-Tag aus. Sie können jederzeit zu Nightly zurückwechseln.",
|
||||||
|
"switching": "Wechsle zu {channel}-Kanal...",
|
||||||
|
"completed": "Erfolgreich zu {channel}-Kanal gewechselt",
|
||||||
|
"failed": "Kanalwechsel fehlgeschlagen"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "Neueste Mitteilungen",
|
"recent": "Neueste Mitteilungen",
|
||||||
"empty": "Keine aktuellen Banner verfügbar.",
|
"empty": "Keine aktuellen Banner verfügbar.",
|
||||||
|
|||||||
+29
-3
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "Preset name...",
|
"presetNamePlaceholder": "Preset name...",
|
||||||
"baseModel": "Base Model",
|
"baseModel": "Base Model",
|
||||||
"baseModelSearchPlaceholder": "Search base models...",
|
"baseModelSearchPlaceholder": "Search base models...",
|
||||||
"modelTags": "Tags (Top 20)",
|
"modelTags": "Tags",
|
||||||
"modelTypes": "Model Types",
|
"modelTypes": "Model Types",
|
||||||
"license": "License",
|
"license": "License",
|
||||||
"noCreditRequired": "No Credit Required",
|
"noCreditRequired": "No Credit Required",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
|
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
|
||||||
"noCreditRequiredTooltip": "Use the model without crediting the creator",
|
"noCreditRequiredTooltip": "Use the model without crediting the creator",
|
||||||
"noTags": "No tags",
|
"noTags": "No tags",
|
||||||
|
"tagSearchPlaceholder": "Search tags...",
|
||||||
|
"noTagMatches": "No tags match the current search.",
|
||||||
"autoTags": "Auto Tags",
|
"autoTags": "Auto Tags",
|
||||||
"noBaseModelMatches": "No base models match the current search.",
|
"noBaseModelMatches": "No base models match the current search.",
|
||||||
"clearAll": "Clear All Filters",
|
"clearAll": "Clear All Filters",
|
||||||
@@ -640,7 +642,13 @@
|
|||||||
"preparing": "Preparing download...",
|
"preparing": "Preparing download...",
|
||||||
"connecting": "Connecting to download server...",
|
"connecting": "Connecting to download server...",
|
||||||
"completed": "Completed",
|
"completed": "Completed",
|
||||||
"downloadComplete": "Download completed successfully"
|
"downloadComplete": "Download completed successfully",
|
||||||
|
"enableCivarchiveApi": "Enable CivArchive API as metadata provider",
|
||||||
|
"enableCivarchiveApiHelp": "When on, CivArchive API is used as a fallback source for model metadata (e.g. for models deleted from CivitAI). Turn off to avoid CivArchive rate limits entirely.",
|
||||||
|
"providerOrder": "Metadata provider fallback order",
|
||||||
|
"providerOrderHelp": "CivitAI API is always tried first. Choose the order of the remaining providers when looking up metadata.",
|
||||||
|
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||||
|
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||||
},
|
},
|
||||||
"proxySettings": {
|
"proxySettings": {
|
||||||
"enableProxy": "Enable App-level Proxy",
|
"enableProxy": "Enable App-level Proxy",
|
||||||
@@ -706,7 +714,9 @@
|
|||||||
"versionsCount": "Local Versions",
|
"versionsCount": "Local Versions",
|
||||||
"versionsCountDesc": "Most versions first",
|
"versionsCountDesc": "Most versions first",
|
||||||
"versionsCountAsc": "Fewest versions first",
|
"versionsCountAsc": "Fewest versions first",
|
||||||
"versionIdDesc": "Newest version first"
|
"versionIdDesc": "Newest version first",
|
||||||
|
"random": "Random",
|
||||||
|
"randomAction": "Randomize (shuffle)"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Refresh model list",
|
"title": "Refresh model list",
|
||||||
@@ -1540,6 +1550,7 @@
|
|||||||
"empty": "No version history available for this model yet.",
|
"empty": "No version history available for this model yet.",
|
||||||
"error": "Failed to load versions.",
|
"error": "Failed to load versions.",
|
||||||
"missingModelId": "This model is missing a Civitai model id.",
|
"missingModelId": "This model is missing a Civitai model id.",
|
||||||
|
"hfGroupInfo": "This is a HuggingFace model group. Open the library to see all versions in the grid.",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "Delete this version from your library?"
|
"delete": "Delete this version from your library?"
|
||||||
},
|
},
|
||||||
@@ -1743,6 +1754,12 @@
|
|||||||
"checkingMessage": "Please wait while we check for the latest version.",
|
"checkingMessage": "Please wait while we check for the latest version.",
|
||||||
"showNotifications": "Show update notifications",
|
"showNotifications": "Show update notifications",
|
||||||
"latestBadge": "Latest",
|
"latestBadge": "Latest",
|
||||||
|
"latestMain": "Latest main",
|
||||||
|
"channel": "Update Channel",
|
||||||
|
"channels": {
|
||||||
|
"release": "Release",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "Preparing update...",
|
"preparing": "Preparing update...",
|
||||||
"installing": "Installing update...",
|
"installing": "Installing update...",
|
||||||
@@ -1763,6 +1780,15 @@
|
|||||||
"warning": "Warning: Nightly builds may contain experimental features and could be unstable.",
|
"warning": "Warning: Nightly builds may contain experimental features and could be unstable.",
|
||||||
"enable": "Enable Nightly Updates"
|
"enable": "Enable Nightly Updates"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "Switch to Nightly Channel",
|
||||||
|
"nightlyMessage": "Switching to Nightly will initialize a Git repository and track the latest main branch commits. Updates will be more frequent but may be unstable. You can switch back to Release at any time.",
|
||||||
|
"releaseTitle": "Switch to Release Channel",
|
||||||
|
"releaseMessage": "Switching to Release will checkout the latest stable release tag. You can switch back to Nightly at any time.",
|
||||||
|
"switching": "Switching to {channel} channel...",
|
||||||
|
"completed": "Successfully switched to {channel} channel",
|
||||||
|
"failed": "Failed to switch channel"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "Recent messages",
|
"recent": "Recent messages",
|
||||||
"empty": "No recent banners yet.",
|
"empty": "No recent banners yet.",
|
||||||
|
|||||||
+30
-4
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "Nombre del preajuste...",
|
"presetNamePlaceholder": "Nombre del preajuste...",
|
||||||
"baseModel": "Modelo base",
|
"baseModel": "Modelo base",
|
||||||
"baseModelSearchPlaceholder": "Buscar modelos base...",
|
"baseModelSearchPlaceholder": "Buscar modelos base...",
|
||||||
"modelTags": "Etiquetas (Top 20)",
|
"modelTags": "Etiquetas",
|
||||||
"modelTypes": "Tipos de modelos",
|
"modelTypes": "Tipos de modelos",
|
||||||
"license": "Licencia",
|
"license": "Licencia",
|
||||||
"noCreditRequired": "Sin crédito requerido",
|
"noCreditRequired": "Sin crédito requerido",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
|
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
|
||||||
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
|
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
|
||||||
"noTags": "Sin etiquetas",
|
"noTags": "Sin etiquetas",
|
||||||
|
"tagSearchPlaceholder": "Buscar etiquetas...",
|
||||||
|
"noTagMatches": "Ninguna etiqueta coincide con la búsqueda actual.",
|
||||||
"autoTags": "Etiquetas automáticas",
|
"autoTags": "Etiquetas automáticas",
|
||||||
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
||||||
"clearAll": "Limpiar todos los filtros",
|
"clearAll": "Limpiar todos los filtros",
|
||||||
@@ -640,7 +642,13 @@
|
|||||||
"preparing": "Preparando descarga...",
|
"preparing": "Preparando descarga...",
|
||||||
"connecting": "Conectando al servidor de descarga...",
|
"connecting": "Conectando al servidor de descarga...",
|
||||||
"completed": "Completado",
|
"completed": "Completado",
|
||||||
"downloadComplete": "Descarga completada exitosamente"
|
"downloadComplete": "Descarga completada exitosamente",
|
||||||
|
"enableCivarchiveApi": "Habilitar CivArchive API como proveedor de metadatos",
|
||||||
|
"enableCivarchiveApiHelp": "Al activarlo, la API de CivArchive se usa como fuente alternativa de metadatos de modelos (p. ej. para modelos eliminados de CivitAI). Desactívelo para evitar por completo los límites de velocidad de CivArchive.",
|
||||||
|
"providerOrder": "Orden de proveedores de metadatos de respaldo",
|
||||||
|
"providerOrderHelp": "La API de CivitAI siempre se intenta primero. Elija el orden de los demás proveedores al buscar metadatos.",
|
||||||
|
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||||
|
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||||
},
|
},
|
||||||
"proxySettings": {
|
"proxySettings": {
|
||||||
"enableProxy": "Habilitar proxy a nivel de aplicación",
|
"enableProxy": "Habilitar proxy a nivel de aplicación",
|
||||||
@@ -706,7 +714,9 @@
|
|||||||
"versionsCount": "Versiones locales",
|
"versionsCount": "Versiones locales",
|
||||||
"versionsCountDesc": "Más versiones primero",
|
"versionsCountDesc": "Más versiones primero",
|
||||||
"versionsCountAsc": "Menos versiones primero",
|
"versionsCountAsc": "Menos versiones primero",
|
||||||
"versionIdDesc": "Versión más nueva primero"
|
"versionIdDesc": "Versión más nueva primero",
|
||||||
|
"random": "Aleatorio",
|
||||||
|
"randomAction": "Aleatorizar (barajar)"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualizar lista de modelos",
|
"title": "Actualizar lista de modelos",
|
||||||
@@ -1540,6 +1550,7 @@
|
|||||||
"empty": "Aún no hay historial de versiones para este modelo.",
|
"empty": "Aún no hay historial de versiones para este modelo.",
|
||||||
"error": "No se pudieron cargar las versiones.",
|
"error": "No se pudieron cargar las versiones.",
|
||||||
"missingModelId": "Este modelo no tiene un ID de modelo de Civitai.",
|
"missingModelId": "Este modelo no tiene un ID de modelo de Civitai.",
|
||||||
|
"hfGroupInfo": "Este es un grupo de modelos de HuggingFace. Abra la biblioteca para ver todas las versiones en la cuadrícula.",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "¿Eliminar esta versión de tu biblioteca?"
|
"delete": "¿Eliminar esta versión de tu biblioteca?"
|
||||||
},
|
},
|
||||||
@@ -1742,7 +1753,13 @@
|
|||||||
"checkingUpdates": "Comprobando actualizaciones...",
|
"checkingUpdates": "Comprobando actualizaciones...",
|
||||||
"checkingMessage": "Por favor espera mientras comprobamos la última versión.",
|
"checkingMessage": "Por favor espera mientras comprobamos la última versión.",
|
||||||
"showNotifications": "Mostrar notificaciones de actualización",
|
"showNotifications": "Mostrar notificaciones de actualización",
|
||||||
"latestBadge": "Último",
|
"latestBadge": "Última",
|
||||||
|
"latestMain": "Rama main",
|
||||||
|
"channel": "Canal de actualizacion",
|
||||||
|
"channels": {
|
||||||
|
"release": "Release",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "Preparando actualización...",
|
"preparing": "Preparando actualización...",
|
||||||
"installing": "Instalando actualización...",
|
"installing": "Instalando actualización...",
|
||||||
@@ -1763,6 +1780,15 @@
|
|||||||
"warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.",
|
"warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.",
|
||||||
"enable": "Habilitar actualizaciones nocturnas"
|
"enable": "Habilitar actualizaciones nocturnas"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "Cambiar a canal Nightly",
|
||||||
|
"nightlyMessage": "Cambiar a Nightly inicializara un repositorio Git y seguira los ultimos commits de la rama main. Las actualizaciones son mas frecuentes pero pueden ser inestables. Puede volver a Release en cualquier momento.",
|
||||||
|
"releaseTitle": "Cambiar a canal Release",
|
||||||
|
"releaseMessage": "Cambiar a Release hara checkout de la ultima etiqueta de version estable. Puede volver a Nightly en cualquier momento.",
|
||||||
|
"switching": "Cambiando a canal {channel}...",
|
||||||
|
"completed": "Cambio a canal {channel} exitoso",
|
||||||
|
"failed": "Error al cambiar de canal"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "Notificaciones recientes",
|
"recent": "Notificaciones recientes",
|
||||||
"empty": "No hay banners recientes.",
|
"empty": "No hay banners recientes.",
|
||||||
|
|||||||
+30
-4
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "Nom du préréglage...",
|
"presetNamePlaceholder": "Nom du préréglage...",
|
||||||
"baseModel": "Modèle de base",
|
"baseModel": "Modèle de base",
|
||||||
"baseModelSearchPlaceholder": "Rechercher des modèles de base...",
|
"baseModelSearchPlaceholder": "Rechercher des modèles de base...",
|
||||||
"modelTags": "Tags (Top 20)",
|
"modelTags": "Tags",
|
||||||
"modelTypes": "Types de modèles",
|
"modelTypes": "Types de modèles",
|
||||||
"license": "Licence",
|
"license": "Licence",
|
||||||
"noCreditRequired": "Crédit non requis",
|
"noCreditRequired": "Crédit non requis",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées",
|
"allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées",
|
||||||
"noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur",
|
"noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur",
|
||||||
"noTags": "Aucun tag",
|
"noTags": "Aucun tag",
|
||||||
|
"tagSearchPlaceholder": "Rechercher des tags...",
|
||||||
|
"noTagMatches": "Aucun tag ne correspond à la recherche actuelle.",
|
||||||
"autoTags": "Auto-Tags",
|
"autoTags": "Auto-Tags",
|
||||||
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
||||||
"clearAll": "Effacer tous les filtres",
|
"clearAll": "Effacer tous les filtres",
|
||||||
@@ -640,7 +642,13 @@
|
|||||||
"preparing": "Préparation du téléchargement...",
|
"preparing": "Préparation du téléchargement...",
|
||||||
"connecting": "Connexion au serveur de téléchargement...",
|
"connecting": "Connexion au serveur de téléchargement...",
|
||||||
"completed": "Terminé",
|
"completed": "Terminé",
|
||||||
"downloadComplete": "Téléchargement terminé avec succès"
|
"downloadComplete": "Téléchargement terminé avec succès",
|
||||||
|
"enableCivarchiveApi": "Activer l'API CivArchive comme fournisseur de métadonnées",
|
||||||
|
"enableCivarchiveApiHelp": "Lorsqu'elle est activée, l'API CivArchive est utilisée comme source de secours pour les métadonnées des modèles (par ex. pour les modèles supprimés de CivitAI). Désactivez pour éviter entièrement les limites de débit de CivArchive.",
|
||||||
|
"providerOrder": "Ordre de secours des fournisseurs de métadonnées",
|
||||||
|
"providerOrderHelp": "L'API CivitAI est toujours essayée en premier. Choisissez l'ordre des autres fournisseurs lors de la recherche de métadonnées.",
|
||||||
|
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||||
|
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||||
},
|
},
|
||||||
"proxySettings": {
|
"proxySettings": {
|
||||||
"enableProxy": "Activer le proxy au niveau de l'application",
|
"enableProxy": "Activer le proxy au niveau de l'application",
|
||||||
@@ -706,7 +714,9 @@
|
|||||||
"versionsCount": "Versions locales",
|
"versionsCount": "Versions locales",
|
||||||
"versionsCountDesc": "Plus de versions d'abord",
|
"versionsCountDesc": "Plus de versions d'abord",
|
||||||
"versionsCountAsc": "Moins de versions d'abord",
|
"versionsCountAsc": "Moins de versions d'abord",
|
||||||
"versionIdDesc": "Version la plus récente d'abord"
|
"versionIdDesc": "Version la plus récente d'abord",
|
||||||
|
"random": "Aléatoire",
|
||||||
|
"randomAction": "Aléatoire (mélanger)"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualiser la liste des modèles",
|
"title": "Actualiser la liste des modèles",
|
||||||
@@ -1540,6 +1550,7 @@
|
|||||||
"empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.",
|
"empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.",
|
||||||
"error": "Échec du chargement des versions.",
|
"error": "Échec du chargement des versions.",
|
||||||
"missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.",
|
"missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.",
|
||||||
|
"hfGroupInfo": "Ceci est un groupe de modèles HuggingFace. Ouvrez la bibliothèque pour voir toutes les versions dans la grille.",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "Supprimer cette version de votre bibliothèque ?"
|
"delete": "Supprimer cette version de votre bibliothèque ?"
|
||||||
},
|
},
|
||||||
@@ -1742,7 +1753,13 @@
|
|||||||
"checkingUpdates": "Vérification des mises à jour...",
|
"checkingUpdates": "Vérification des mises à jour...",
|
||||||
"checkingMessage": "Veuillez patienter pendant la vérification de la dernière version.",
|
"checkingMessage": "Veuillez patienter pendant la vérification de la dernière version.",
|
||||||
"showNotifications": "Afficher les notifications de mise à jour",
|
"showNotifications": "Afficher les notifications de mise à jour",
|
||||||
"latestBadge": "Dernier",
|
"latestBadge": "Dernière",
|
||||||
|
"latestMain": "Branche main",
|
||||||
|
"channel": "Canal de mise a jour",
|
||||||
|
"channels": {
|
||||||
|
"release": "Release",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "Préparation de la mise à jour...",
|
"preparing": "Préparation de la mise à jour...",
|
||||||
"installing": "Installation de la mise à jour...",
|
"installing": "Installation de la mise à jour...",
|
||||||
@@ -1763,6 +1780,15 @@
|
|||||||
"warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.",
|
"warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.",
|
||||||
"enable": "Activer les mises à jour nightly"
|
"enable": "Activer les mises à jour nightly"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "Passer au canal Nightly",
|
||||||
|
"nightlyMessage": "Passer a Nightly initialisera un depot Git et suivra les derniers commits de la branche main. Les mises a jour sont plus frequentes mais peuvent etre instables. Vous pouvez revenir a Release a tout moment.",
|
||||||
|
"releaseTitle": "Passer au canal Release",
|
||||||
|
"releaseMessage": "Passer a Release passera au dernier tag de version stable. Vous pouvez revenir a Nightly a tout moment.",
|
||||||
|
"switching": "Passage au canal {channel}...",
|
||||||
|
"completed": "Basculement vers le canal {channel} reussi",
|
||||||
|
"failed": "Echec du changement de canal"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "Messages récents",
|
"recent": "Messages récents",
|
||||||
"empty": "Aucune bannière récente.",
|
"empty": "Aucune bannière récente.",
|
||||||
|
|||||||
+30
-4
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "שם קביעה מראש...",
|
"presetNamePlaceholder": "שם קביעה מראש...",
|
||||||
"baseModel": "מודל בסיס",
|
"baseModel": "מודל בסיס",
|
||||||
"baseModelSearchPlaceholder": "חפש מודלי בסיס...",
|
"baseModelSearchPlaceholder": "חפש מודלי בסיס...",
|
||||||
"modelTags": "תגיות (20 המובילות)",
|
"modelTags": "תגיות",
|
||||||
"modelTypes": "סוגי מודלים",
|
"modelTypes": "סוגי מודלים",
|
||||||
"license": "רישיון",
|
"license": "רישיון",
|
||||||
"noCreditRequired": "ללא קרדיט נדרש",
|
"noCreditRequired": "ללא קרדיט נדרש",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
|
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
|
||||||
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
|
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
|
||||||
"noTags": "ללא תגיות",
|
"noTags": "ללא תגיות",
|
||||||
|
"tagSearchPlaceholder": "חיפוש תגיות...",
|
||||||
|
"noTagMatches": "אין תגיות שתואמות את החיפוש הנוכחי.",
|
||||||
"autoTags": "תגיות אוטומטיות",
|
"autoTags": "תגיות אוטומטיות",
|
||||||
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
||||||
"clearAll": "נקה את כל המסננים",
|
"clearAll": "נקה את כל המסננים",
|
||||||
@@ -640,7 +642,13 @@
|
|||||||
"preparing": "מכין הורדה...",
|
"preparing": "מכין הורדה...",
|
||||||
"connecting": "מתחבר לשרת ההורדות...",
|
"connecting": "מתחבר לשרת ההורדות...",
|
||||||
"completed": "הושלם",
|
"completed": "הושלם",
|
||||||
"downloadComplete": "ההורדה הושלמה בהצלחה"
|
"downloadComplete": "ההורדה הושלמה בהצלחה",
|
||||||
|
"enableCivarchiveApi": "הפעל את CivArchive API כספק מטא-נתונים",
|
||||||
|
"enableCivarchiveApiHelp": "כאשר מופעל, CivArchive API משמש כמקור גיבוי למטא-נתונים של מודלים (למשל עבור מודלים שנמחקו מ-CivitAI). כבה כדי להימנע לחלוטין ממגבלות הקצב של CivArchive.",
|
||||||
|
"providerOrder": "סדר ספקי מטא-נתונים לגיבוי",
|
||||||
|
"providerOrderHelp": "CivitAI API תמיד מנוסה ראשון. בחר את סדר הספקים הנותרים בעת חיפוש מטא-נתונים.",
|
||||||
|
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||||
|
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||||
},
|
},
|
||||||
"proxySettings": {
|
"proxySettings": {
|
||||||
"enableProxy": "הפעל פרוקסי ברמת האפליקציה",
|
"enableProxy": "הפעל פרוקסי ברמת האפליקציה",
|
||||||
@@ -706,7 +714,9 @@
|
|||||||
"versionsCount": "גרסאות מקומיות",
|
"versionsCount": "גרסאות מקומיות",
|
||||||
"versionsCountDesc": "הכי הרבה גרסאות ראשונות",
|
"versionsCountDesc": "הכי הרבה גרסאות ראשונות",
|
||||||
"versionsCountAsc": "הכי מעט גרסאות ראשונות",
|
"versionsCountAsc": "הכי מעט גרסאות ראשונות",
|
||||||
"versionIdDesc": "גרסה חדשה ביותר ראשונה"
|
"versionIdDesc": "גרסה חדשה ביותר ראשונה",
|
||||||
|
"random": "אקראי",
|
||||||
|
"randomAction": "ערבוב אקראי"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מודלים",
|
"title": "רענן רשימת מודלים",
|
||||||
@@ -1540,6 +1550,7 @@
|
|||||||
"empty": "אין עדיין היסטוריית גרסאות למודל זה.",
|
"empty": "אין עדיין היסטוריית גרסאות למודל זה.",
|
||||||
"error": "טעינת הגרסאות נכשלה.",
|
"error": "טעינת הגרסאות נכשלה.",
|
||||||
"missingModelId": "למודל זה אין מזהה מודל של Civitai.",
|
"missingModelId": "למודל זה אין מזהה מודל של Civitai.",
|
||||||
|
"hfGroupInfo": "זוהי קבוצת דגמים של HuggingFace. פתח את הספרייה כדי לראות את כל הגרסאות ברשת.",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "למחוק גרסה זו מהספרייה שלך?"
|
"delete": "למחוק גרסה זו מהספרייה שלך?"
|
||||||
},
|
},
|
||||||
@@ -1742,7 +1753,13 @@
|
|||||||
"checkingUpdates": "בודק עדכונים...",
|
"checkingUpdates": "בודק עדכונים...",
|
||||||
"checkingMessage": "אנא המתן בזמן שאנו בודקים את הגרסה האחרונה.",
|
"checkingMessage": "אנא המתן בזמן שאנו בודקים את הגרסה האחרונה.",
|
||||||
"showNotifications": "הצג התראות עדכון",
|
"showNotifications": "הצג התראות עדכון",
|
||||||
"latestBadge": "עדכן",
|
"latestBadge": "אחרון",
|
||||||
|
"latestMain": "ענף main",
|
||||||
|
"channel": "ערוץ עדכון",
|
||||||
|
"channels": {
|
||||||
|
"release": "Release",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "מכין עדכון...",
|
"preparing": "מכין עדכון...",
|
||||||
"installing": "מתקין עדכון...",
|
"installing": "מתקין עדכון...",
|
||||||
@@ -1763,6 +1780,15 @@
|
|||||||
"warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.",
|
"warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.",
|
||||||
"enable": "הפעל עדכונים ליליים"
|
"enable": "הפעל עדכונים ליליים"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "מעבר לערוץ Nightly",
|
||||||
|
"nightlyMessage": "מעבר ל-Nightly יאתחל מאגר Git ויעקוב אחר הקומיטים האחרונים בענף main. העדכונים תכופים יותר אך עשויים להיות לא יציבים. ניתן לחזור ל-Release בכל עת.",
|
||||||
|
"releaseTitle": "מעבר לערוץ Release",
|
||||||
|
"releaseMessage": "מעבר ל-Release יעבור לתגית הגרסה היציבה האחרונה. ניתן לחזור ל-Nightly בכל עת.",
|
||||||
|
"switching": "מעבר לערוץ {channel}...",
|
||||||
|
"completed": "המעבר לערוץ {channel} הושלם",
|
||||||
|
"failed": "החלפת ערוץ נכשלה"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "הודעות אחרונות",
|
"recent": "הודעות אחרונות",
|
||||||
"empty": "אין כרגע באנרים אחרונים.",
|
"empty": "אין כרגע באנרים אחרונים.",
|
||||||
|
|||||||
+29
-3
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "プリセット名...",
|
"presetNamePlaceholder": "プリセット名...",
|
||||||
"baseModel": "ベースモデル",
|
"baseModel": "ベースモデル",
|
||||||
"baseModelSearchPlaceholder": "ベースモデルを検索...",
|
"baseModelSearchPlaceholder": "ベースモデルを検索...",
|
||||||
"modelTags": "タグ(上位20)",
|
"modelTags": "タグ",
|
||||||
"modelTypes": "モデルタイプ",
|
"modelTypes": "モデルタイプ",
|
||||||
"license": "ライセンス",
|
"license": "ライセンス",
|
||||||
"noCreditRequired": "クレジット不要",
|
"noCreditRequired": "クレジット不要",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
|
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
|
||||||
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
|
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
|
||||||
"noTags": "タグなし",
|
"noTags": "タグなし",
|
||||||
|
"tagSearchPlaceholder": "タグを検索...",
|
||||||
|
"noTagMatches": "現在の検索に一致するタグはありません。",
|
||||||
"autoTags": "自動タグ",
|
"autoTags": "自動タグ",
|
||||||
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
||||||
"clearAll": "すべてのフィルタをクリア",
|
"clearAll": "すべてのフィルタをクリア",
|
||||||
@@ -640,7 +642,13 @@
|
|||||||
"preparing": "ダウンロードを準備中...",
|
"preparing": "ダウンロードを準備中...",
|
||||||
"connecting": "ダウンロードサーバーに接続中...",
|
"connecting": "ダウンロードサーバーに接続中...",
|
||||||
"completed": "完了",
|
"completed": "完了",
|
||||||
"downloadComplete": "ダウンロードが正常に完了しました"
|
"downloadComplete": "ダウンロードが正常に完了しました",
|
||||||
|
"enableCivarchiveApi": "CivArchive API をメタデータプロバイダーとして有効化",
|
||||||
|
"enableCivarchiveApiHelp": "有効にすると、CivArchive API がモデルメタデータの代替ソースとして使用されます(例:CivitAI から削除されたモデルの場合)。オフにすると、CivArchive のレート制限を完全に回避できます。",
|
||||||
|
"providerOrder": "メタデータプロバイダーのフォールバック順序",
|
||||||
|
"providerOrderHelp": "CivitAI API が常に最初に試行されます。メタデータ検索時の残りのプロバイダーの順序を選択してください。",
|
||||||
|
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||||
|
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||||
},
|
},
|
||||||
"proxySettings": {
|
"proxySettings": {
|
||||||
"enableProxy": "アプリレベルのプロキシを有効化",
|
"enableProxy": "アプリレベルのプロキシを有効化",
|
||||||
@@ -706,7 +714,9 @@
|
|||||||
"versionsCount": "ローカルバージョン数",
|
"versionsCount": "ローカルバージョン数",
|
||||||
"versionsCountDesc": "バージョン数の多い順",
|
"versionsCountDesc": "バージョン数の多い順",
|
||||||
"versionsCountAsc": "バージョン数の少ない順",
|
"versionsCountAsc": "バージョン数の少ない順",
|
||||||
"versionIdDesc": "最新バージョン順"
|
"versionIdDesc": "最新バージョン順",
|
||||||
|
"random": "ランダム",
|
||||||
|
"randomAction": "シャッフル(ランダム)"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "モデルリストを更新",
|
"title": "モデルリストを更新",
|
||||||
@@ -1540,6 +1550,7 @@
|
|||||||
"empty": "このモデルにはまだバージョン履歴がありません。",
|
"empty": "このモデルにはまだバージョン履歴がありません。",
|
||||||
"error": "バージョンの読み込みに失敗しました。",
|
"error": "バージョンの読み込みに失敗しました。",
|
||||||
"missingModelId": "このモデルにはCivitaiのモデルIDがありません。",
|
"missingModelId": "このモデルにはCivitaiのモデルIDがありません。",
|
||||||
|
"hfGroupInfo": "これは HuggingFace モデルグループです。ライブラリを開いてグリッドですべてのバージョンを表示してください。",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "このバージョンをライブラリから削除しますか?"
|
"delete": "このバージョンをライブラリから削除しますか?"
|
||||||
},
|
},
|
||||||
@@ -1743,6 +1754,12 @@
|
|||||||
"checkingMessage": "最新バージョンを確認しています。お待ちください。",
|
"checkingMessage": "最新バージョンを確認しています。お待ちください。",
|
||||||
"showNotifications": "更新通知を表示",
|
"showNotifications": "更新通知を表示",
|
||||||
"latestBadge": "最新",
|
"latestBadge": "最新",
|
||||||
|
"latestMain": "Main ブランチ",
|
||||||
|
"channel": "更新チャンネル",
|
||||||
|
"channels": {
|
||||||
|
"release": "リリース",
|
||||||
|
"nightly": "ナイトリー"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "更新を準備中...",
|
"preparing": "更新を準備中...",
|
||||||
"installing": "更新をインストール中...",
|
"installing": "更新をインストール中...",
|
||||||
@@ -1763,6 +1780,15 @@
|
|||||||
"warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。",
|
"warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。",
|
||||||
"enable": "ナイトリー更新を有効にする"
|
"enable": "ナイトリー更新を有効にする"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "ナイトリーチャンネルに切り替え",
|
||||||
|
"nightlyMessage": "ナイトリーに切り替えると、Gitリポジトリが初期化され、mainブランチの最新コミットを追跡します。更新頻度は高くなりますが、不安定な場合があります。いつでもリリース版に戻せます。",
|
||||||
|
"releaseTitle": "リリースチャンネルに切り替え",
|
||||||
|
"releaseMessage": "リリースに切り替えると、最新の安定版タグにチェックアウトされます。いつでもNightlyに戻せます。",
|
||||||
|
"switching": "{channel} チャンネルに切り替え中...",
|
||||||
|
"completed": "{channel} チャンネルに切り替えました",
|
||||||
|
"failed": "チャンネルの切り替えに失敗しました"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "最近の通知",
|
"recent": "最近の通知",
|
||||||
"empty": "最近のバナーはありません。",
|
"empty": "最近のバナーはありません。",
|
||||||
|
|||||||
+29
-3
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "프리셋 이름...",
|
"presetNamePlaceholder": "프리셋 이름...",
|
||||||
"baseModel": "베이스 모델",
|
"baseModel": "베이스 모델",
|
||||||
"baseModelSearchPlaceholder": "베이스 모델 검색...",
|
"baseModelSearchPlaceholder": "베이스 모델 검색...",
|
||||||
"modelTags": "태그 (상위 20개)",
|
"modelTags": "태그",
|
||||||
"modelTypes": "모델 유형",
|
"modelTypes": "모델 유형",
|
||||||
"license": "라이선스",
|
"license": "라이선스",
|
||||||
"noCreditRequired": "크레딧 표기 없음",
|
"noCreditRequired": "크레딧 표기 없음",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
|
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
|
||||||
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
|
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
|
||||||
"noTags": "태그 없음",
|
"noTags": "태그 없음",
|
||||||
|
"tagSearchPlaceholder": "태그 검색...",
|
||||||
|
"noTagMatches": "현재 검색과 일치하는 태그가 없습니다.",
|
||||||
"autoTags": "자동 태그",
|
"autoTags": "자동 태그",
|
||||||
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
||||||
"clearAll": "모든 필터 지우기",
|
"clearAll": "모든 필터 지우기",
|
||||||
@@ -640,7 +642,13 @@
|
|||||||
"preparing": "다운로드 준비 중...",
|
"preparing": "다운로드 준비 중...",
|
||||||
"connecting": "다운로드 서버에 연결 중...",
|
"connecting": "다운로드 서버에 연결 중...",
|
||||||
"completed": "완료됨",
|
"completed": "완료됨",
|
||||||
"downloadComplete": "다운로드가 성공적으로 완료되었습니다"
|
"downloadComplete": "다운로드가 성공적으로 완료되었습니다",
|
||||||
|
"enableCivarchiveApi": "CivArchive API를 메타데이터 제공자로 활성화",
|
||||||
|
"enableCivarchiveApiHelp": "활성화하면 CivArchive API가 모델 메타데이터의 대체 소스로 사용됩니다 (예: CivitAI에서 삭제된 모델의 경우). 비활성화하면 CivArchive의 속도 제한을 완전히 피할 수 있습니다.",
|
||||||
|
"providerOrder": "메타데이터 제공자 폴백 순서",
|
||||||
|
"providerOrderHelp": "CivitAI API가 항상 먼저 시도됩니다. 메타데이터 조회 시 나머지 제공자의 순서를 선택하세요.",
|
||||||
|
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||||
|
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||||
},
|
},
|
||||||
"proxySettings": {
|
"proxySettings": {
|
||||||
"enableProxy": "앱 수준 프록시 활성화",
|
"enableProxy": "앱 수준 프록시 활성화",
|
||||||
@@ -706,7 +714,9 @@
|
|||||||
"versionsCount": "로컬 버전 수",
|
"versionsCount": "로컬 버전 수",
|
||||||
"versionsCountDesc": "버전 수 많은 순",
|
"versionsCountDesc": "버전 수 많은 순",
|
||||||
"versionsCountAsc": "버전 수 적은 순",
|
"versionsCountAsc": "버전 수 적은 순",
|
||||||
"versionIdDesc": "최신 버전순"
|
"versionIdDesc": "최신 버전순",
|
||||||
|
"random": "랜덤",
|
||||||
|
"randomAction": "셔플 (무작위)"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "모델 목록 새로고침",
|
"title": "모델 목록 새로고침",
|
||||||
@@ -1540,6 +1550,7 @@
|
|||||||
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
|
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
|
||||||
"error": "버전을 불러오지 못했습니다.",
|
"error": "버전을 불러오지 못했습니다.",
|
||||||
"missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.",
|
"missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.",
|
||||||
|
"hfGroupInfo": "HuggingFace 모델 그룹입니다. 라이브러리를 열어 그리드에서 모든 버전을 확인하세요.",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
|
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
|
||||||
},
|
},
|
||||||
@@ -1743,6 +1754,12 @@
|
|||||||
"checkingMessage": "최신 버전을 확인하는 동안 잠시 기다려주세요.",
|
"checkingMessage": "최신 버전을 확인하는 동안 잠시 기다려주세요.",
|
||||||
"showNotifications": "업데이트 알림 표시",
|
"showNotifications": "업데이트 알림 표시",
|
||||||
"latestBadge": "최신",
|
"latestBadge": "최신",
|
||||||
|
"latestMain": "Main 브랜치",
|
||||||
|
"channel": "업데이트 채널",
|
||||||
|
"channels": {
|
||||||
|
"release": "릴리스",
|
||||||
|
"nightly": "나이틀리"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "업데이트 준비 중...",
|
"preparing": "업데이트 준비 중...",
|
||||||
"installing": "업데이트 설치 중...",
|
"installing": "업데이트 설치 중...",
|
||||||
@@ -1763,6 +1780,15 @@
|
|||||||
"warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.",
|
"warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.",
|
||||||
"enable": "나이틀리 업데이트 활성화"
|
"enable": "나이틀리 업데이트 활성화"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "나이틀리 채널로 전환",
|
||||||
|
"nightlyMessage": "나이틀리로 전환하면 Git 저장소가 초기화되고 main 브랜치의 최신 커밋을 추적합니다. 업데이트 빈도는 높지만 불안정할 수 있습니다. 언제든지 릴리스로 돌아갈 수 있습니다.",
|
||||||
|
"releaseTitle": "릴리스 채널로 전환",
|
||||||
|
"releaseMessage": "릴리스로 전환하면 최신 안정 버전 태그로 체크아웃됩니다. 언제든지 나이틀리로 돌아갈 수 있습니다.",
|
||||||
|
"switching": "{channel} 채널로 전환 중...",
|
||||||
|
"completed": "{channel} 채널로 전환 완료",
|
||||||
|
"failed": "채널 전환 실패"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "최근 알림",
|
"recent": "최근 알림",
|
||||||
"empty": "최근 배너가 없습니다.",
|
"empty": "최근 배너가 없습니다.",
|
||||||
|
|||||||
+30
-4
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "Имя пресета...",
|
"presetNamePlaceholder": "Имя пресета...",
|
||||||
"baseModel": "Базовая модель",
|
"baseModel": "Базовая модель",
|
||||||
"baseModelSearchPlaceholder": "Поиск базовых моделей...",
|
"baseModelSearchPlaceholder": "Поиск базовых моделей...",
|
||||||
"modelTags": "Теги (Топ 20)",
|
"modelTags": "Теги",
|
||||||
"modelTypes": "Типы моделей",
|
"modelTypes": "Типы моделей",
|
||||||
"license": "Лицензия",
|
"license": "Лицензия",
|
||||||
"noCreditRequired": "Без указания авторства",
|
"noCreditRequired": "Без указания авторства",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
|
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
|
||||||
"noCreditRequiredTooltip": "Использование модели без указания автора",
|
"noCreditRequiredTooltip": "Использование модели без указания автора",
|
||||||
"noTags": "Без тегов",
|
"noTags": "Без тегов",
|
||||||
|
"tagSearchPlaceholder": "Поиск тегов...",
|
||||||
|
"noTagMatches": "Нет тегов, соответствующих текущему поиску.",
|
||||||
"autoTags": "Авто-теги",
|
"autoTags": "Авто-теги",
|
||||||
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
||||||
"clearAll": "Очистить все фильтры",
|
"clearAll": "Очистить все фильтры",
|
||||||
@@ -640,7 +642,13 @@
|
|||||||
"preparing": "Подготовка к загрузке...",
|
"preparing": "Подготовка к загрузке...",
|
||||||
"connecting": "Подключение к серверу загрузки...",
|
"connecting": "Подключение к серверу загрузки...",
|
||||||
"completed": "Завершено",
|
"completed": "Завершено",
|
||||||
"downloadComplete": "Загрузка успешно завершена"
|
"downloadComplete": "Загрузка успешно завершена",
|
||||||
|
"enableCivarchiveApi": "Включить CivArchive API как источник метаданных",
|
||||||
|
"enableCivarchiveApiHelp": "При включении CivArchive API используется как резервный источник метаданных моделей (например, для моделей, удалённых с CivitAI). Отключите, чтобы полностью избежать ограничений скорости CivArchive.",
|
||||||
|
"providerOrder": "Порядок резервных источников метаданных",
|
||||||
|
"providerOrderHelp": "CivitAI API всегда проверяется первым. Выберите порядок остальных источников при поиске метаданных.",
|
||||||
|
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||||
|
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||||
},
|
},
|
||||||
"proxySettings": {
|
"proxySettings": {
|
||||||
"enableProxy": "Включить прокси на уровне приложения",
|
"enableProxy": "Включить прокси на уровне приложения",
|
||||||
@@ -706,7 +714,9 @@
|
|||||||
"versionsCount": "Локальные версии",
|
"versionsCount": "Локальные версии",
|
||||||
"versionsCountDesc": "Сначала больше версий",
|
"versionsCountDesc": "Сначала больше версий",
|
||||||
"versionsCountAsc": "Сначала меньше версий",
|
"versionsCountAsc": "Сначала меньше версий",
|
||||||
"versionIdDesc": "Сначала новые версии"
|
"versionIdDesc": "Сначала новые версии",
|
||||||
|
"random": "Случайно",
|
||||||
|
"randomAction": "Перемешать"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список моделей",
|
"title": "Обновить список моделей",
|
||||||
@@ -1540,6 +1550,7 @@
|
|||||||
"empty": "Для этой модели пока нет истории версий.",
|
"empty": "Для этой модели пока нет истории версий.",
|
||||||
"error": "Не удалось загрузить версии.",
|
"error": "Не удалось загрузить версии.",
|
||||||
"missingModelId": "У этой модели отсутствует идентификатор модели Civitai.",
|
"missingModelId": "У этой модели отсутствует идентификатор модели Civitai.",
|
||||||
|
"hfGroupInfo": "Это группа моделей HuggingFace. Откройте библиотеку, чтобы увидеть все версии в сетке.",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "Удалить эту версию из библиотеки?"
|
"delete": "Удалить эту версию из библиотеки?"
|
||||||
},
|
},
|
||||||
@@ -1742,7 +1753,13 @@
|
|||||||
"checkingUpdates": "Проверка обновлений...",
|
"checkingUpdates": "Проверка обновлений...",
|
||||||
"checkingMessage": "Пожалуйста, подождите, пока мы проверяем последнюю версию.",
|
"checkingMessage": "Пожалуйста, подождите, пока мы проверяем последнюю версию.",
|
||||||
"showNotifications": "Показывать уведомления об обновлениях",
|
"showNotifications": "Показывать уведомления об обновлениях",
|
||||||
"latestBadge": "Последний",
|
"latestBadge": "Последняя",
|
||||||
|
"latestMain": "Ветка main",
|
||||||
|
"channel": "Канал обновлений",
|
||||||
|
"channels": {
|
||||||
|
"release": "Релиз",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "Подготовка обновления...",
|
"preparing": "Подготовка обновления...",
|
||||||
"installing": "Установка обновления...",
|
"installing": "Установка обновления...",
|
||||||
@@ -1763,6 +1780,15 @@
|
|||||||
"warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.",
|
"warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.",
|
||||||
"enable": "Включить ночные обновления"
|
"enable": "Включить ночные обновления"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "Переключиться на Nightly",
|
||||||
|
"nightlyMessage": "Переключение на Nightly инициализирует Git-репозиторий и отслеживает последние коммиты ветки main. Обновления чаще, но могут быть нестабильными. Вы можете вернуться к Release в любое время.",
|
||||||
|
"releaseTitle": "Переключиться на Release",
|
||||||
|
"releaseMessage": "Переключение на Release выполнит checkout последнего стабильного тега. Вы можете вернуться к Nightly в любое время.",
|
||||||
|
"switching": "Переключение на канал {channel}...",
|
||||||
|
"completed": "Успешно переключено на канал {channel}",
|
||||||
|
"failed": "Не удалось переключить канал"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "Недавние уведомления",
|
"recent": "Недавние уведомления",
|
||||||
"empty": "Недавних баннеров нет.",
|
"empty": "Недавних баннеров нет.",
|
||||||
|
|||||||
+29
-3
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "预设名称...",
|
"presetNamePlaceholder": "预设名称...",
|
||||||
"baseModel": "基础模型",
|
"baseModel": "基础模型",
|
||||||
"baseModelSearchPlaceholder": "搜索基础模型...",
|
"baseModelSearchPlaceholder": "搜索基础模型...",
|
||||||
"modelTags": "标签(前20)",
|
"modelTags": "标签",
|
||||||
"modelTypes": "模型类型",
|
"modelTypes": "模型类型",
|
||||||
"license": "许可证",
|
"license": "许可证",
|
||||||
"noCreditRequired": "无需署名",
|
"noCreditRequired": "无需署名",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
|
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
|
||||||
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
|
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
|
||||||
"noTags": "无标签",
|
"noTags": "无标签",
|
||||||
|
"tagSearchPlaceholder": "搜索标签...",
|
||||||
|
"noTagMatches": "没有匹配当前搜索的标签。",
|
||||||
"autoTags": "自动标签",
|
"autoTags": "自动标签",
|
||||||
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
||||||
"clearAll": "清除所有筛选",
|
"clearAll": "清除所有筛选",
|
||||||
@@ -640,7 +642,13 @@
|
|||||||
"preparing": "正在准备下载...",
|
"preparing": "正在准备下载...",
|
||||||
"connecting": "正在连接下载服务器...",
|
"connecting": "正在连接下载服务器...",
|
||||||
"completed": "已完成",
|
"completed": "已完成",
|
||||||
"downloadComplete": "下载成功完成"
|
"downloadComplete": "下载成功完成",
|
||||||
|
"enableCivarchiveApi": "启用 CivArchive API 作为元数据提供者",
|
||||||
|
"enableCivarchiveApiHelp": "开启后,CivArchive API 将作为模型元数据的备用来源(例如用于已从 CivitAI 删除的模型)。关闭可完全避免 CivArchive 的速率限制。",
|
||||||
|
"providerOrder": "元数据提供者回退顺序",
|
||||||
|
"providerOrderHelp": "CivitAI API 始终优先尝试。选择查找元数据时其余提供者的顺序。",
|
||||||
|
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||||
|
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||||
},
|
},
|
||||||
"proxySettings": {
|
"proxySettings": {
|
||||||
"enableProxy": "启用应用级代理",
|
"enableProxy": "启用应用级代理",
|
||||||
@@ -706,7 +714,9 @@
|
|||||||
"versionsCount": "本地版本数",
|
"versionsCount": "本地版本数",
|
||||||
"versionsCountDesc": "版本数从多到少",
|
"versionsCountDesc": "版本数从多到少",
|
||||||
"versionsCountAsc": "版本数从少到多",
|
"versionsCountAsc": "版本数从少到多",
|
||||||
"versionIdDesc": "最新版本优先"
|
"versionIdDesc": "最新版本优先",
|
||||||
|
"random": "随机",
|
||||||
|
"randomAction": "随机排序(洗牌)"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新模型列表",
|
"title": "刷新模型列表",
|
||||||
@@ -1540,6 +1550,7 @@
|
|||||||
"empty": "该模型还没有版本历史。",
|
"empty": "该模型还没有版本历史。",
|
||||||
"error": "加载版本失败。",
|
"error": "加载版本失败。",
|
||||||
"missingModelId": "该模型缺少 Civitai 模型 ID。",
|
"missingModelId": "该模型缺少 Civitai 模型 ID。",
|
||||||
|
"hfGroupInfo": "这是一个 HuggingFace 模型组。打开库页面即可在网格中查看所有版本。",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "从库中删除此版本?"
|
"delete": "从库中删除此版本?"
|
||||||
},
|
},
|
||||||
@@ -1743,6 +1754,12 @@
|
|||||||
"checkingMessage": "请稍候,正在检查最新版本。",
|
"checkingMessage": "请稍候,正在检查最新版本。",
|
||||||
"showNotifications": "显示更新通知",
|
"showNotifications": "显示更新通知",
|
||||||
"latestBadge": "最新",
|
"latestBadge": "最新",
|
||||||
|
"latestMain": "Main 分支",
|
||||||
|
"channel": "更新频道",
|
||||||
|
"channels": {
|
||||||
|
"release": "稳定版",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "正在准备更新...",
|
"preparing": "正在准备更新...",
|
||||||
"installing": "正在安装更新...",
|
"installing": "正在安装更新...",
|
||||||
@@ -1763,6 +1780,15 @@
|
|||||||
"warning": "警告:Nightly 版本可能包含实验性功能,可能不稳定。",
|
"warning": "警告:Nightly 版本可能包含实验性功能,可能不稳定。",
|
||||||
"enable": "启用 Nightly 更新"
|
"enable": "启用 Nightly 更新"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "切换到 Nightly",
|
||||||
|
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
|
||||||
|
"releaseTitle": "切换到稳定版",
|
||||||
|
"releaseMessage": "切换到稳定版将检出最新的发布标签。可随时切换回每日构建版。",
|
||||||
|
"switching": "正在切换到 {channel} 频道...",
|
||||||
|
"completed": "已切换到 {channel} 频道",
|
||||||
|
"failed": "切换频道失败"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "最近的通知",
|
"recent": "最近的通知",
|
||||||
"empty": "暂无最近的横幅通知。",
|
"empty": "暂无最近的横幅通知。",
|
||||||
|
|||||||
+29
-3
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "預設名稱...",
|
"presetNamePlaceholder": "預設名稱...",
|
||||||
"baseModel": "基礎模型",
|
"baseModel": "基礎模型",
|
||||||
"baseModelSearchPlaceholder": "搜尋基礎模型...",
|
"baseModelSearchPlaceholder": "搜尋基礎模型...",
|
||||||
"modelTags": "標籤(前 20)",
|
"modelTags": "標籤",
|
||||||
"modelTypes": "模型類型",
|
"modelTypes": "模型類型",
|
||||||
"license": "授權",
|
"license": "授權",
|
||||||
"noCreditRequired": "無需署名",
|
"noCreditRequired": "無需署名",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
|
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
|
||||||
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
|
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
|
||||||
"noTags": "無標籤",
|
"noTags": "無標籤",
|
||||||
|
"tagSearchPlaceholder": "搜尋標籤...",
|
||||||
|
"noTagMatches": "沒有符合目前搜尋的標籤。",
|
||||||
"autoTags": "自動標籤",
|
"autoTags": "自動標籤",
|
||||||
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
||||||
"clearAll": "清除所有篩選",
|
"clearAll": "清除所有篩選",
|
||||||
@@ -640,7 +642,13 @@
|
|||||||
"preparing": "準備下載中...",
|
"preparing": "準備下載中...",
|
||||||
"connecting": "正在連接下載伺服器...",
|
"connecting": "正在連接下載伺服器...",
|
||||||
"completed": "已完成",
|
"completed": "已完成",
|
||||||
"downloadComplete": "下載成功完成"
|
"downloadComplete": "下載成功完成",
|
||||||
|
"enableCivarchiveApi": "啟用 CivArchive API 作為中繼資料提供者",
|
||||||
|
"enableCivarchiveApiHelp": "開啟後,CivArchive API 將作為模型中繼資料的備用來源(例如用於已從 CivitAI 刪除的模型)。關閉可完全避免 CivArchive 的速率限制。",
|
||||||
|
"providerOrder": "中繼資料提供者回退順序",
|
||||||
|
"providerOrderHelp": "CivitAI API 始終優先嘗試。選擇查詢中繼資料時其餘提供者的順序。",
|
||||||
|
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||||
|
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||||
},
|
},
|
||||||
"proxySettings": {
|
"proxySettings": {
|
||||||
"enableProxy": "啟用應用程式代理",
|
"enableProxy": "啟用應用程式代理",
|
||||||
@@ -706,7 +714,9 @@
|
|||||||
"versionsCount": "本地版本數",
|
"versionsCount": "本地版本數",
|
||||||
"versionsCountDesc": "版本數從多到少",
|
"versionsCountDesc": "版本數從多到少",
|
||||||
"versionsCountAsc": "版本數從少到多",
|
"versionsCountAsc": "版本數從少到多",
|
||||||
"versionIdDesc": "最新版本優先"
|
"versionIdDesc": "最新版本優先",
|
||||||
|
"random": "隨機",
|
||||||
|
"randomAction": "隨機排序(洗牌)"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理模型列表",
|
"title": "重新整理模型列表",
|
||||||
@@ -1540,6 +1550,7 @@
|
|||||||
"empty": "此模型尚無版本歷史。",
|
"empty": "此模型尚無版本歷史。",
|
||||||
"error": "載入版本失敗。",
|
"error": "載入版本失敗。",
|
||||||
"missingModelId": "此模型缺少 Civitai 模型 ID。",
|
"missingModelId": "此模型缺少 Civitai 模型 ID。",
|
||||||
|
"hfGroupInfo": "這是一個 HuggingFace 模型組。打開庫頁面即可在網格中查看所有版本。",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"delete": "要從庫中刪除此版本嗎?"
|
"delete": "要從庫中刪除此版本嗎?"
|
||||||
},
|
},
|
||||||
@@ -1743,6 +1754,12 @@
|
|||||||
"checkingMessage": "請稍候,正在檢查最新版本。",
|
"checkingMessage": "請稍候,正在檢查最新版本。",
|
||||||
"showNotifications": "顯示更新通知",
|
"showNotifications": "顯示更新通知",
|
||||||
"latestBadge": "最新",
|
"latestBadge": "最新",
|
||||||
|
"latestMain": "Main 分支",
|
||||||
|
"channel": "更新頻道",
|
||||||
|
"channels": {
|
||||||
|
"release": "稳定版",
|
||||||
|
"nightly": "Nightly"
|
||||||
|
},
|
||||||
"updateProgress": {
|
"updateProgress": {
|
||||||
"preparing": "正在準備更新...",
|
"preparing": "正在準備更新...",
|
||||||
"installing": "正在安裝更新...",
|
"installing": "正在安裝更新...",
|
||||||
@@ -1763,6 +1780,15 @@
|
|||||||
"warning": "警告:Nightly 版本可能包含實驗性功能且可能不穩定。",
|
"warning": "警告:Nightly 版本可能包含實驗性功能且可能不穩定。",
|
||||||
"enable": "啟用 Nightly 更新"
|
"enable": "啟用 Nightly 更新"
|
||||||
},
|
},
|
||||||
|
"channelSwitch": {
|
||||||
|
"nightlyTitle": "切换到 Nightly",
|
||||||
|
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
|
||||||
|
"releaseTitle": "切换到稳定版",
|
||||||
|
"releaseMessage": "切換到穩定版將檢出最新的發布標籤。可隨時切換回每日構建版。",
|
||||||
|
"switching": "正在切換到 {channel} 頻道...",
|
||||||
|
"completed": "已切換到 {channel} 頻道",
|
||||||
|
"failed": "切換頻道失敗"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"recent": "最新通知",
|
"recent": "最新通知",
|
||||||
"empty": "目前沒有最近的橫幅通知。",
|
"empty": "目前沒有最近的橫幅通知。",
|
||||||
|
|||||||
@@ -359,6 +359,47 @@ class Config:
|
|||||||
"Failed to rename legacy 'default' library: %s", rename_error
|
"Failed to rename legacy 'default' library: %s", rename_error
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Clean up a stale "default" library entry that has no meaningful
|
||||||
|
# paths configured (e.g. leftover bootstrap artifact). This only
|
||||||
|
# fires when "comfyui" already exists so we never delete the last
|
||||||
|
# remaining library.
|
||||||
|
if (
|
||||||
|
"default" in libraries
|
||||||
|
and "comfyui" in libraries
|
||||||
|
and isinstance(default_library, Mapping)
|
||||||
|
):
|
||||||
|
default_folder_paths = _normalize_library_folder_paths(
|
||||||
|
default_library
|
||||||
|
)
|
||||||
|
default_extra_paths = default_library.get("extra_folder_paths", {})
|
||||||
|
has_meaningful_paths = bool(default_folder_paths) or bool(
|
||||||
|
default_extra_paths
|
||||||
|
) or any(
|
||||||
|
default_library.get(key)
|
||||||
|
for key in (
|
||||||
|
"default_lora_root",
|
||||||
|
"default_checkpoint_root",
|
||||||
|
"default_unet_root",
|
||||||
|
"default_embedding_root",
|
||||||
|
"recipes_path",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not has_meaningful_paths:
|
||||||
|
try:
|
||||||
|
settings_service.delete_library("default")
|
||||||
|
libraries_changed = True
|
||||||
|
logger.info(
|
||||||
|
"Removed stale 'default' library entry "
|
||||||
|
"with no meaningful paths configured"
|
||||||
|
)
|
||||||
|
libraries = settings_service.get_libraries()
|
||||||
|
comfy_library = libraries.get("comfyui", {})
|
||||||
|
except Exception as delete_error:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to remove stale 'default' library: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
default_lora_root = _resolve_valid_default_root(
|
default_lora_root = _resolve_valid_default_root(
|
||||||
comfy_library.get("default_lora_root", ""),
|
comfy_library.get("default_lora_root", ""),
|
||||||
list(self.loras_roots or []),
|
list(self.loras_roots or []),
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
"""Constants used by the metadata collector"""
|
"""Constants used by the metadata collector"""
|
||||||
|
|
||||||
|
# Sentinel value for clip_skip to distinguish "unconnected / widget default"
|
||||||
|
# from "user wired value 0". Both ComfyUI CLIPSetLastLayer (-24..-1) and
|
||||||
|
# A1111 conventions treat 0 as meaningless for clip skipping, but users may
|
||||||
|
# explicitly wire 0 to the overwrite node to express "no clip skip / default".
|
||||||
|
CLIP_SKIP_SENTINEL = -25
|
||||||
|
|
||||||
# Metadata categories
|
# Metadata categories
|
||||||
MODELS = "models"
|
MODELS = "models"
|
||||||
PROMPTS = "prompts"
|
PROMPTS = "prompts"
|
||||||
@@ -9,6 +15,14 @@ EMBEDDINGS = "embeddings"
|
|||||||
SIZE = "size"
|
SIZE = "size"
|
||||||
IMAGES = "images"
|
IMAGES = "images"
|
||||||
IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes
|
IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes
|
||||||
|
OVERWRITE = "overwrite" # Manual metadata overwrite from MetadataOverwriteLM node
|
||||||
|
|
||||||
|
# Field names that the MetadataOverwriteLM node and its extractor share
|
||||||
|
METADATA_OVERWRITE_FIELDS = (
|
||||||
|
"prompt", "negative_prompt", "seed", "steps", "cfg_scale",
|
||||||
|
"sampler", "scheduler", "model", "loras", "size",
|
||||||
|
"clip_skip", "additional_data",
|
||||||
|
)
|
||||||
|
|
||||||
# Complete list of categories to track
|
# Complete list of categories to track
|
||||||
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, EMBEDDINGS, SIZE, IMAGES]
|
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, EMBEDDINGS, SIZE, IMAGES, OVERWRITE]
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ class MetadataHook:
|
|||||||
|
|
||||||
# Record inputs before execution
|
# Record inputs before execution
|
||||||
if node_id is not None:
|
if node_id is not None:
|
||||||
registry.record_node_execution(node_id, class_type, input_data_all, None)
|
return_types = getattr(obj, 'RETURN_TYPES', None)
|
||||||
|
registry.record_node_execution(node_id, class_type, input_data_all, None, return_types=return_types)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
|
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
|
||||||
|
|
||||||
@@ -114,7 +115,8 @@ class MetadataHook:
|
|||||||
|
|
||||||
# Record outputs after execution
|
# Record outputs after execution
|
||||||
if node_id is not None:
|
if node_id is not None:
|
||||||
registry.update_node_execution(node_id, class_type, results)
|
return_types = getattr(obj, 'RETURN_TYPES', None)
|
||||||
|
registry.update_node_execution(node_id, class_type, results, return_types=return_types)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
|
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
|
||||||
|
|
||||||
@@ -136,6 +138,9 @@ class MetadataHook:
|
|||||||
if hasattr(prompt, 'original_prompt'):
|
if hasattr(prompt, 'original_prompt'):
|
||||||
registry.set_current_prompt(prompt)
|
registry.set_current_prompt(prompt)
|
||||||
|
|
||||||
|
# Store extra_data for accessing full workflow node properties
|
||||||
|
registry.set_extra_data(extra_data)
|
||||||
|
|
||||||
# Execute the original function
|
# Execute the original function
|
||||||
return original_execute(*args, **kwargs)
|
return original_execute(*args, **kwargs)
|
||||||
|
|
||||||
@@ -163,7 +168,8 @@ class MetadataHook:
|
|||||||
class_type = obj.__class__.__name__
|
class_type = obj.__class__.__name__
|
||||||
node_id = unique_id
|
node_id = unique_id
|
||||||
if node_id is not None:
|
if node_id is not None:
|
||||||
registry.record_node_execution(node_id, class_type, input_data_all, None)
|
return_types = getattr(obj, 'RETURN_TYPES', None)
|
||||||
|
registry.record_node_execution(node_id, class_type, input_data_all, None, return_types=return_types)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
|
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
|
||||||
|
|
||||||
@@ -180,7 +186,8 @@ class MetadataHook:
|
|||||||
class_type = obj.__class__.__name__
|
class_type = obj.__class__.__name__
|
||||||
node_id = unique_id
|
node_id = unique_id
|
||||||
if node_id is not None:
|
if node_id is not None:
|
||||||
registry.update_node_execution(node_id, class_type, results)
|
return_types = getattr(obj, 'RETURN_TYPES', None)
|
||||||
|
registry.update_node_execution(node_id, class_type, results, return_types=return_types)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
|
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
|
||||||
|
|
||||||
@@ -202,6 +209,9 @@ class MetadataHook:
|
|||||||
if hasattr(prompt, 'original_prompt'):
|
if hasattr(prompt, 'original_prompt'):
|
||||||
registry.set_current_prompt(prompt)
|
registry.set_current_prompt(prompt)
|
||||||
|
|
||||||
|
# Store extra_data for accessing full workflow node properties
|
||||||
|
registry.set_extra_data(extra_data)
|
||||||
|
|
||||||
# Execute the original function
|
# Execute the original function
|
||||||
return await original_execute(*args, **kwargs)
|
return await original_execute(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,68 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from .constants import IMAGES
|
from .constants import IMAGES
|
||||||
|
|
||||||
# Check if running in standalone mode
|
# Check if running in standalone mode
|
||||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||||
|
|
||||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IS_SAMPLER
|
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IS_SAMPLER, OVERWRITE
|
||||||
|
from .node_extractors import NODE_EXTRACTORS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Keys that identify metadata hint marks stored in node.properties.lm_marker_role
|
||||||
|
_META_MARK_PREFIX = "meta_"
|
||||||
|
_MARK_PRIMARY_MODEL = "primary_model"
|
||||||
|
_MARK_PRIMARY_SAMPLER = "primary_sampler"
|
||||||
|
_MARK_POSITIVE_PROMPT = "positive_prompt"
|
||||||
|
_MARK_NEGATIVE_PROMPT = "negative_prompt"
|
||||||
|
|
||||||
class MetadataProcessor:
|
class MetadataProcessor:
|
||||||
"""Process and format collected metadata"""
|
"""Process and format collected metadata"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_user_marks(metadata):
|
||||||
|
"""Scan workflow nodes (from extra_data.extra_pnginfo.workflow) for user-assigned
|
||||||
|
metadata hint marks stored in node.properties.lm_marker_role.
|
||||||
|
|
||||||
|
Returns a dict mapping mark type keys to node IDs.
|
||||||
|
Example: {'primary_model': '42', 'primary_sampler': '17'}
|
||||||
|
"""
|
||||||
|
marks: dict[str, str] = {}
|
||||||
|
|
||||||
|
# Primary source: extra_data.extra_pnginfo.workflow.nodes (has full properties)
|
||||||
|
extra_data = metadata.get("extra_data")
|
||||||
|
if extra_data and isinstance(extra_data, dict):
|
||||||
|
extra_pnginfo = extra_data.get("extra_pnginfo", {})
|
||||||
|
if isinstance(extra_pnginfo, dict):
|
||||||
|
workflow = extra_pnginfo.get("workflow", {})
|
||||||
|
nodes = workflow.get("nodes", [])
|
||||||
|
for node in nodes:
|
||||||
|
node_id = str(node.get("id", ""))
|
||||||
|
role = node.get("properties", {}).get("lm_marker_role", "")
|
||||||
|
if role.startswith(_META_MARK_PREFIX):
|
||||||
|
mark_type = role[len(_META_MARK_PREFIX):]
|
||||||
|
if mark_type in marks:
|
||||||
|
logger.warning(
|
||||||
|
"Duplicate meta hint '%s': node %s (previous: %s), "
|
||||||
|
"last match wins",
|
||||||
|
mark_type, node_id, marks[mark_type],
|
||||||
|
)
|
||||||
|
marks[mark_type] = node_id
|
||||||
|
|
||||||
|
# Fallback: try prompt.original_prompt (API-only submissions may not have workflow)
|
||||||
|
if not marks:
|
||||||
|
prompt = metadata.get("current_prompt")
|
||||||
|
if prompt and getattr(prompt, "original_prompt", None):
|
||||||
|
for node_id, node_data in prompt.original_prompt.items():
|
||||||
|
role = node_data.get("properties", {}).get("lm_marker_role", "")
|
||||||
|
if role.startswith(_META_MARK_PREFIX):
|
||||||
|
mark_type = role[len(_META_MARK_PREFIX):]
|
||||||
|
marks[mark_type] = node_id
|
||||||
|
|
||||||
|
return marks
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_primary_sampler(metadata, downstream_id=None):
|
def find_primary_sampler(metadata, downstream_id=None):
|
||||||
"""
|
"""
|
||||||
@@ -471,17 +524,54 @@ class MetadataProcessor:
|
|||||||
"checkpoint": None,
|
"checkpoint": None,
|
||||||
"loras": "",
|
"loras": "",
|
||||||
"size": None,
|
"size": None,
|
||||||
"clip_skip": None
|
"clip_skip": None,
|
||||||
|
"additional_data": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get the prompt object for node relationship tracing
|
# Get the prompt object for node relationship tracing
|
||||||
prompt = metadata.get("current_prompt")
|
prompt = metadata.get("current_prompt")
|
||||||
|
|
||||||
# Find the primary KSampler node
|
# ---- User marks: override heuristic inference with user-assigned hints ----
|
||||||
|
user_marks = MetadataProcessor._get_user_marks(metadata)
|
||||||
|
|
||||||
|
# Find the primary KSampler node (user mark takes priority)
|
||||||
|
primary_sampler_id = None
|
||||||
|
primary_sampler = None
|
||||||
|
if _MARK_PRIMARY_SAMPLER in user_marks:
|
||||||
|
marked_id = user_marks[_MARK_PRIMARY_SAMPLER]
|
||||||
|
sampler_data = metadata.get(SAMPLING, {}).get(marked_id)
|
||||||
|
if sampler_data and sampler_data.get(IS_SAMPLER):
|
||||||
|
primary_sampler_id = marked_id
|
||||||
|
primary_sampler = sampler_data
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"User-marked primary sampler %s has no runtime metadata, "
|
||||||
|
"falling back to heuristic",
|
||||||
|
marked_id,
|
||||||
|
)
|
||||||
|
if primary_sampler is None:
|
||||||
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
|
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
|
||||||
|
|
||||||
# Directly get checkpoint from metadata instead of tracing
|
# Resolve checkpoint / model (user mark takes priority)
|
||||||
# Pass primary_sampler_id to avoid redundant calculation
|
if _MARK_PRIMARY_MODEL in user_marks:
|
||||||
|
marked_id = user_marks[_MARK_PRIMARY_MODEL]
|
||||||
|
if marked_id in metadata.get(MODELS, {}):
|
||||||
|
params["checkpoint"] = metadata[MODELS][marked_id].get("name")
|
||||||
|
else:
|
||||||
|
extra_data = metadata.get("extra_data")
|
||||||
|
extra_pnginfo = extra_data.get("extra_pnginfo", {}) if extra_data and isinstance(extra_data, dict) else {}
|
||||||
|
workflow = extra_pnginfo.get("workflow", {}) if isinstance(extra_pnginfo, dict) else {}
|
||||||
|
node_type = "unknown"
|
||||||
|
for n in workflow.get("nodes", []):
|
||||||
|
if str(n.get("id", "")) == marked_id:
|
||||||
|
node_type = n.get("type", "unknown")
|
||||||
|
break
|
||||||
|
logger.warning(
|
||||||
|
"User-marked primary model %s (type=%s, registered=%s) has no runtime metadata, "
|
||||||
|
"falling back to heuristic",
|
||||||
|
marked_id, node_type, node_type in NODE_EXTRACTORS,
|
||||||
|
)
|
||||||
|
if params["checkpoint"] is None:
|
||||||
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata, id, primary_sampler_id)
|
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata, id, primary_sampler_id)
|
||||||
if checkpoint:
|
if checkpoint:
|
||||||
params["checkpoint"] = checkpoint
|
params["checkpoint"] = checkpoint
|
||||||
@@ -540,6 +630,21 @@ class MetadataProcessor:
|
|||||||
# For SamplerCustom, handle any additional parameters
|
# For SamplerCustom, handle any additional parameters
|
||||||
MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, primary_sampler_id, params)
|
MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, primary_sampler_id, params)
|
||||||
|
|
||||||
|
# ---- User marks: override prompts with explicitly tagged nodes ----
|
||||||
|
prompts_data = metadata.get(PROMPTS, {})
|
||||||
|
if _MARK_POSITIVE_PROMPT in user_marks:
|
||||||
|
pos_id = user_marks[_MARK_POSITIVE_PROMPT]
|
||||||
|
if pos_id in prompts_data:
|
||||||
|
prompt_text = prompts_data[pos_id].get("text") or prompts_data[pos_id].get("positive_text")
|
||||||
|
if prompt_text:
|
||||||
|
params["prompt"] = prompt_text
|
||||||
|
if _MARK_NEGATIVE_PROMPT in user_marks:
|
||||||
|
neg_id = user_marks[_MARK_NEGATIVE_PROMPT]
|
||||||
|
if neg_id in prompts_data:
|
||||||
|
prompt_text = prompts_data[neg_id].get("text") or prompts_data[neg_id].get("negative_text")
|
||||||
|
if prompt_text:
|
||||||
|
params["negative_prompt"] = prompt_text
|
||||||
|
|
||||||
# Size extraction is same for all sampler types
|
# Size extraction is same for all sampler types
|
||||||
# Check if the sampler itself has size information (from latent_image)
|
# Check if the sampler itself has size information (from latent_image)
|
||||||
if primary_sampler_id in metadata.get(SIZE, {}):
|
if primary_sampler_id in metadata.get(SIZE, {}):
|
||||||
@@ -569,6 +674,25 @@ class MetadataProcessor:
|
|||||||
if params["clip_skip"] is None:
|
if params["clip_skip"] is None:
|
||||||
params["clip_skip"] = "1"
|
params["clip_skip"] = "1"
|
||||||
|
|
||||||
|
# ---- Apply manual metadata overwrites ----
|
||||||
|
for overwrite_info in metadata.get(OVERWRITE, {}).values():
|
||||||
|
overwrite_params = overwrite_info.get("parameters", {})
|
||||||
|
for key, value in overwrite_params.items():
|
||||||
|
if key == "clip_skip":
|
||||||
|
# Accept any value from overwrite node (sentinel -25 already
|
||||||
|
# filtered upstream). Needed because falsy check treats 0
|
||||||
|
# as "not set" even though 0 is a valid wired input here.
|
||||||
|
params[key] = value
|
||||||
|
elif value: # truthy check — only overwrite when user provided a real value
|
||||||
|
params[key] = value
|
||||||
|
|
||||||
|
# Bridge: the overwrite node exposes the field as "model" (more accurate),
|
||||||
|
# but the internal pipeline key remains "checkpoint" for backward compatibility
|
||||||
|
# with A1111 metadata format and downstream consumers.
|
||||||
|
if params.get("model"):
|
||||||
|
params["checkpoint"] = params["model"]
|
||||||
|
del params["model"]
|
||||||
|
|
||||||
return params
|
return params
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import time
|
import time
|
||||||
from nodes import NODE_CLASS_MAPPINGS # type: ignore
|
from nodes import NODE_CLASS_MAPPINGS # type: ignore
|
||||||
from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor
|
from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor
|
||||||
from .constants import METADATA_CATEGORIES, IMAGES
|
from .constants import METADATA_CATEGORIES, IMAGES, OVERWRITE
|
||||||
|
|
||||||
|
|
||||||
class MetadataRegistry:
|
class MetadataRegistry:
|
||||||
@@ -61,6 +61,7 @@ class MetadataRegistry:
|
|||||||
{
|
{
|
||||||
"execution_order": [],
|
"execution_order": [],
|
||||||
"current_prompt": None, # Will store the prompt object
|
"current_prompt": None, # Will store the prompt object
|
||||||
|
"extra_data": None, # Will store the API extra_data for workflow metadata
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -75,6 +76,11 @@ class MetadataRegistry:
|
|||||||
# Store the prompt in the metadata for later relationship tracing
|
# Store the prompt in the metadata for later relationship tracing
|
||||||
self.prompt_metadata[self.current_prompt_id]["current_prompt"] = prompt
|
self.prompt_metadata[self.current_prompt_id]["current_prompt"] = prompt
|
||||||
|
|
||||||
|
def set_extra_data(self, extra_data):
|
||||||
|
"""Store the API extra_data (contains extra_pnginfo.workflow with node properties)"""
|
||||||
|
if self.current_prompt_id and self.current_prompt_id in self.prompt_metadata:
|
||||||
|
self.prompt_metadata[self.current_prompt_id]["extra_data"] = extra_data
|
||||||
|
|
||||||
def get_metadata(self, prompt_id=None):
|
def get_metadata(self, prompt_id=None):
|
||||||
"""Get collected metadata for a prompt"""
|
"""Get collected metadata for a prompt"""
|
||||||
key = prompt_id if prompt_id is not None else self.current_prompt_id
|
key = prompt_id if prompt_id is not None else self.current_prompt_id
|
||||||
@@ -122,20 +128,28 @@ class MetadataRegistry:
|
|||||||
cache_key = f"{node_id}:{class_type}"
|
cache_key = f"{node_id}:{class_type}"
|
||||||
|
|
||||||
# Check if this node type is relevant for metadata collection
|
# Check if this node type is relevant for metadata collection
|
||||||
if class_type in NODE_EXTRACTORS:
|
if class_type in NODE_EXTRACTORS or cache_key in self.node_cache:
|
||||||
# Check if we have cached metadata for this node
|
# Check if we have cached metadata for this node
|
||||||
if cache_key in self.node_cache:
|
if cache_key in self.node_cache:
|
||||||
cached_data = self.node_cache[cache_key]
|
cached_data = self.node_cache[cache_key]
|
||||||
|
|
||||||
|
# Detect bypass (mode=4) / mute (mode=2) — these nodes
|
||||||
|
# were intentionally disabled and should not contribute
|
||||||
|
# overwrite values from a previous execution's cache.
|
||||||
|
node_mode = node_data.get("mode", 0)
|
||||||
|
node_is_disabled = node_mode in (2, 4)
|
||||||
|
|
||||||
# Apply cached metadata to the current metadata
|
# Apply cached metadata to the current metadata
|
||||||
for category in self.metadata_categories:
|
for category in self.metadata_categories:
|
||||||
|
if category == OVERWRITE and node_is_disabled:
|
||||||
|
continue
|
||||||
if category in cached_data and node_id in cached_data[category]:
|
if category in cached_data and node_id in cached_data[category]:
|
||||||
if node_id not in metadata[category]:
|
if node_id not in metadata[category]:
|
||||||
metadata[category][node_id] = cached_data[category][
|
metadata[category][node_id] = cached_data[category][
|
||||||
node_id
|
node_id
|
||||||
]
|
]
|
||||||
|
|
||||||
def record_node_execution(self, node_id, class_type, inputs, outputs):
|
def record_node_execution(self, node_id, class_type, inputs, outputs, return_types=None):
|
||||||
"""Record information about a node's execution"""
|
"""Record information about a node's execution"""
|
||||||
if not self.current_prompt_id:
|
if not self.current_prompt_id:
|
||||||
return
|
return
|
||||||
@@ -158,17 +172,18 @@ class MetadataRegistry:
|
|||||||
|
|
||||||
# Extract node-specific metadata
|
# Extract node-specific metadata
|
||||||
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
|
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
|
||||||
extractor.extract(
|
if extractor is GenericNodeExtractor:
|
||||||
node_id,
|
extractor.extract(node_id, processed_inputs, outputs,
|
||||||
processed_inputs,
|
|
||||||
outputs,
|
|
||||||
self.prompt_metadata[self.current_prompt_id],
|
self.prompt_metadata[self.current_prompt_id],
|
||||||
)
|
return_types=return_types)
|
||||||
|
else:
|
||||||
|
extractor.extract(node_id, processed_inputs, outputs,
|
||||||
|
self.prompt_metadata[self.current_prompt_id])
|
||||||
|
|
||||||
# Cache this node's metadata
|
# Cache this node's metadata
|
||||||
self._cache_node_metadata(node_id, class_type)
|
self._cache_node_metadata(node_id, class_type)
|
||||||
|
|
||||||
def update_node_execution(self, node_id, class_type, outputs):
|
def update_node_execution(self, node_id, class_type, outputs, return_types=None):
|
||||||
"""Update node metadata with output information"""
|
"""Update node metadata with output information"""
|
||||||
if not self.current_prompt_id:
|
if not self.current_prompt_id:
|
||||||
return
|
return
|
||||||
@@ -179,8 +194,16 @@ class MetadataRegistry:
|
|||||||
# Use the same extractor to update with outputs
|
# Use the same extractor to update with outputs
|
||||||
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
|
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
|
||||||
if hasattr(extractor, "update"):
|
if hasattr(extractor, "update"):
|
||||||
|
if extractor is GenericNodeExtractor:
|
||||||
extractor.update(
|
extractor.update(
|
||||||
node_id, processed_outputs, self.prompt_metadata[self.current_prompt_id]
|
node_id, processed_outputs,
|
||||||
|
self.prompt_metadata[self.current_prompt_id],
|
||||||
|
return_types=return_types,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
extractor.update(
|
||||||
|
node_id, processed_outputs,
|
||||||
|
self.prompt_metadata[self.current_prompt_id],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update the cached metadata for this node
|
# Update the cached metadata for this node
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER, OVERWRITE
|
||||||
|
from .overwrite_utils import collect_overwrite_params
|
||||||
|
|
||||||
|
|
||||||
def _store_checkpoint_metadata(metadata, node_id, model_name):
|
def _store_checkpoint_metadata(metadata, node_id, model_name):
|
||||||
@@ -31,10 +32,77 @@ class NodeMetadataExtractor:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
class GenericNodeExtractor(NodeMetadataExtractor):
|
class GenericNodeExtractor(NodeMetadataExtractor):
|
||||||
"""Default extractor for nodes without specific handling"""
|
"""Fallback extractor with type-signature-based detection.
|
||||||
|
|
||||||
|
When a node is not in the NODE_EXTRACTORS registry, the hook layer
|
||||||
|
passes ``return_types`` from ``obj.RETURN_TYPES``:
|
||||||
|
|
||||||
|
* ``MODEL`` output: common input fields (ckpt_name, unet_name, etc.)
|
||||||
|
are checked for a model file name and stored as checkpoint metadata.
|
||||||
|
* ``CONDITIONING`` output: common text input fields are checked for
|
||||||
|
prompt text and stored as prompt metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Input field names that carry a model path in loader-style nodes.
|
||||||
|
_MODEL_NAME_FIELDS = (
|
||||||
|
"ckpt_name", "unet_name", "model_path", "model_name", "gguf_name",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extensions used by checkpoint_scanner.py — only record values that look
|
||||||
|
# like real model filenames to avoid capturing unrelated string fields.
|
||||||
|
_MODEL_EXTENSIONS = {
|
||||||
|
".ckpt", ".pt", ".pt2", ".bin", ".pth", ".safetensors", ".pkl", ".sft", ".gguf",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Input field names that may carry prompt text in encoder-style nodes.
|
||||||
|
_TEXT_FIELDS = ("text", "clip_l", "t5xxl", "prompt", "positive", "negative")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract(node_id, inputs, outputs, metadata):
|
def extract(node_id, inputs, outputs, metadata, return_types=None):
|
||||||
pass
|
if return_types is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# — MODEL loader detection (checkpoint / UNET / GGUF) —
|
||||||
|
if "MODEL" in return_types or any("MODEL" in str(t) for t in return_types):
|
||||||
|
for field in GenericNodeExtractor._MODEL_NAME_FIELDS:
|
||||||
|
val = inputs.get(field)
|
||||||
|
if val and isinstance(val, str) and val.strip():
|
||||||
|
name = val.strip()
|
||||||
|
if not any(name.lower().endswith(ext) for ext in GenericNodeExtractor._MODEL_EXTENSIONS):
|
||||||
|
continue
|
||||||
|
_store_checkpoint_metadata(metadata, node_id, name)
|
||||||
|
return
|
||||||
|
|
||||||
|
# — CONDITIONING encoder detection (CLIPTextEncode, Flux, custom) —
|
||||||
|
if "CONDITIONING" in return_types or any("CONDITIONING" in str(t) for t in return_types):
|
||||||
|
text = None
|
||||||
|
for field in GenericNodeExtractor._TEXT_FIELDS:
|
||||||
|
val = inputs.get(field)
|
||||||
|
if val and isinstance(val, str) and val.strip():
|
||||||
|
text = val.strip()
|
||||||
|
break
|
||||||
|
if text:
|
||||||
|
prompt_data = metadata.setdefault(PROMPTS, {})
|
||||||
|
prompt_data[node_id] = {
|
||||||
|
"text": text,
|
||||||
|
"node_id": node_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(node_id, outputs, metadata, return_types=None):
|
||||||
|
if return_types is None:
|
||||||
|
return
|
||||||
|
if "CONDITIONING" not in return_types and not any(
|
||||||
|
"CONDITIONING" in str(t) for t in return_types
|
||||||
|
):
|
||||||
|
return
|
||||||
|
if node_id not in metadata.get(PROMPTS, {}):
|
||||||
|
return
|
||||||
|
if outputs and isinstance(outputs, list) and len(outputs) > 0:
|
||||||
|
if isinstance(outputs[0], tuple) and len(outputs[0]) > 0:
|
||||||
|
cond = outputs[0][0]
|
||||||
|
if cond is not None:
|
||||||
|
metadata[PROMPTS][node_id]["conditioning"] = cond
|
||||||
|
|
||||||
class CheckpointLoaderExtractor(NodeMetadataExtractor):
|
class CheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1154,6 +1222,28 @@ class CR_ApplyControlNetStackExtractor(NodeMetadataExtractor):
|
|||||||
metadata[PROMPTS][node_id]["positive_encoded"] = transformed_positive
|
metadata[PROMPTS][node_id]["positive_encoded"] = transformed_positive
|
||||||
metadata[PROMPTS][node_id]["negative_encoded"] = transformed_negative
|
metadata[PROMPTS][node_id]["negative_encoded"] = transformed_negative
|
||||||
|
|
||||||
|
class MetadataOverwriteExtractor(NodeMetadataExtractor):
|
||||||
|
"""Extract manually specified metadata from MetadataOverwriteLM node.
|
||||||
|
|
||||||
|
Stores truthy input values under the OVERWRITE category so that
|
||||||
|
extract_generation_params can merge them over the inferred params.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
overwrite_params = collect_overwrite_params(inputs)
|
||||||
|
|
||||||
|
if overwrite_params:
|
||||||
|
metadata.setdefault(OVERWRITE, {})
|
||||||
|
metadata[OVERWRITE][node_id] = {
|
||||||
|
"parameters": overwrite_params,
|
||||||
|
"node_id": node_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Registry of node-specific extractors
|
# Registry of node-specific extractors
|
||||||
# Keys are node class names
|
# Keys are node class names
|
||||||
NODE_EXTRACTORS = {
|
NODE_EXTRACTORS = {
|
||||||
@@ -1221,5 +1311,7 @@ NODE_EXTRACTORS = {
|
|||||||
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider
|
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider
|
||||||
# Image
|
# Image
|
||||||
"VAEDecode": VAEDecodeExtractor, # Added VAEDecode extractor
|
"VAEDecode": VAEDecodeExtractor, # Added VAEDecode extractor
|
||||||
|
# Metadata overwrite
|
||||||
|
"MetadataOverwriteLM": MetadataOverwriteExtractor,
|
||||||
# Add other nodes as needed
|
# Add other nodes as needed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Shared helpers for Metadata Overwrite node metadata collection.
|
||||||
|
|
||||||
|
Used by both the MetadataOverwriteLM node (execution time) and the
|
||||||
|
MetadataOverwriteExtractor (hook time) so the conversion/filtering logic
|
||||||
|
cannot drift between the two paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from ..utils.utils import model_patcher_to_name
|
||||||
|
from .constants import CLIP_SKIP_SENTINEL, METADATA_OVERWRITE_FIELDS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_overwrite_params(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Convert node input values into non-default overwrite parameters.
|
||||||
|
|
||||||
|
For most fields, a falsy value (empty string, 0) means "not set" and is
|
||||||
|
skipped. clip_skip uses a dedicated sentinel (-25) so that a wired value
|
||||||
|
of 0 is preserved. The ``model`` field accepts either a manual string or
|
||||||
|
a wired MODEL (ModelPatcher) connection; in the latter case the source
|
||||||
|
model name is extracted from the patcher's ``cached_patcher_init`` and
|
||||||
|
stored as a ComfyUI-style relative path.
|
||||||
|
"""
|
||||||
|
result: Dict[str, Any] = {}
|
||||||
|
for key in METADATA_OVERWRITE_FIELDS:
|
||||||
|
value = values.get(key)
|
||||||
|
if key == "model" and not isinstance(value, str):
|
||||||
|
value = model_patcher_to_name(value)
|
||||||
|
if value is None:
|
||||||
|
logger.warning(
|
||||||
|
"Could not extract model name from wired MODEL input "
|
||||||
|
"(no cached_patcher_init); model metadata overwrite skipped"
|
||||||
|
)
|
||||||
|
if key == "clip_skip":
|
||||||
|
if value != CLIP_SKIP_SENTINEL:
|
||||||
|
result[key] = value
|
||||||
|
elif value:
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Create Hook LoRA (LoraManager) — multi-LoRA hook node compatible with ComfyUI's built-in hook pipeline.
|
||||||
|
|
||||||
|
Produces ``("HOOKS",)`` output that chains seamlessly with downstream hook consumers
|
||||||
|
(ConditioningSetProperties, SetHookKeyframes, CombineHooks, SetClipHooks, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from ..utils.utils import get_lora_info_absolute
|
||||||
|
from .utils import (
|
||||||
|
FlexibleOptionalInputType,
|
||||||
|
any_type,
|
||||||
|
apply_lora_syntax_format,
|
||||||
|
get_loras_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateHookLoraLM:
|
||||||
|
NAME = "Create Hook LoRA (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/hooks"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"text": (
|
||||||
|
"AUTOCOMPLETE_TEXT_LORAS",
|
||||||
|
{
|
||||||
|
"placeholder": "Search LoRAs to add...",
|
||||||
|
"tooltip": (
|
||||||
|
"Search and select LoRAs. Each LoRA gets its own "
|
||||||
|
"model/clip strength. Hooks chain with prev_hooks."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("HOOKS", "STRING", "STRING")
|
||||||
|
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
|
||||||
|
FUNCTION = "create_hook"
|
||||||
|
|
||||||
|
def create_hook(self, text: str, **kwargs):
|
||||||
|
"""Create a HookGroup from the selected LoRAs, chained with prev_hooks.
|
||||||
|
|
||||||
|
Each active LoRA from the widget is loaded and wrapped in a WeightHook
|
||||||
|
via :func:`comfy.hooks.create_hook_lora`. All hooks are combined into a
|
||||||
|
single group and returned alongside trigger words and a human-readable
|
||||||
|
summary of the active LoRAs.
|
||||||
|
"""
|
||||||
|
del text # used by the frontend widget only
|
||||||
|
|
||||||
|
# Lazy imports: comfy is not available in CI/test environment at module level
|
||||||
|
import comfy.hooks # type: ignore # noqa: C0415
|
||||||
|
import comfy.utils # type: ignore # noqa: C0415
|
||||||
|
|
||||||
|
prev_hooks: comfy.hooks.HookGroup | None = kwargs.get("prev_hooks")
|
||||||
|
|
||||||
|
hook_group = prev_hooks.clone() if prev_hooks is not None else comfy.hooks.HookGroup()
|
||||||
|
|
||||||
|
all_trigger_words: list[str] = []
|
||||||
|
active_loras: list[tuple[str, float, float]] = []
|
||||||
|
|
||||||
|
for lora in get_loras_list(kwargs):
|
||||||
|
if not lora.get("active", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
lora_name = apply_lora_syntax_format(lora["name"])
|
||||||
|
model_strength = float(lora["strength"])
|
||||||
|
clip_strength = float(lora.get("clipStrength", model_strength))
|
||||||
|
|
||||||
|
# Skip useless no-op entries (both strengths are zero)
|
||||||
|
if model_strength == 0.0 and clip_strength == 0.0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||||
|
if not lora_path or not os.path.isfile(lora_path):
|
||||||
|
logger.warning("LoRA '%s' not found — skipping", lora_name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
lora_weights = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
||||||
|
|
||||||
|
lora_hooks = comfy.hooks.create_hook_lora(
|
||||||
|
lora=lora_weights,
|
||||||
|
strength_model=model_strength,
|
||||||
|
strength_clip=clip_strength,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load LoRA '%s' — skipping", lora_name)
|
||||||
|
continue
|
||||||
|
hook_group = hook_group.clone_and_combine(lora_hooks)
|
||||||
|
|
||||||
|
active_loras.append((lora_name, model_strength, clip_strength))
|
||||||
|
all_trigger_words.extend(trigger_words)
|
||||||
|
|
||||||
|
# Format trigger words (group mode separator)
|
||||||
|
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||||
|
|
||||||
|
# Format active LoRAs summary
|
||||||
|
formatted_loras = []
|
||||||
|
for name, model_s, clip_s in active_loras:
|
||||||
|
if abs(model_s - clip_s) > 0.001:
|
||||||
|
formatted_loras.append(
|
||||||
|
f"<lora:{name}:{model_s}:{clip_s}>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
formatted_loras.append(f"<lora:{name}:{model_s}>")
|
||||||
|
active_loras_text = " ".join(formatted_loras)
|
||||||
|
|
||||||
|
return (hook_group, trigger_words_text, active_loras_text)
|
||||||
+2
-17
@@ -1,6 +1,5 @@
|
|||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
|
|
||||||
import comfy.sd # type: ignore
|
import comfy.sd # type: ignore
|
||||||
import comfy.utils # type: ignore
|
import comfy.utils # type: ignore
|
||||||
@@ -14,6 +13,7 @@ from .utils import (
|
|||||||
extract_lora_name,
|
extract_lora_name,
|
||||||
get_loras_list,
|
get_loras_list,
|
||||||
nunchaku_load_lora,
|
nunchaku_load_lora,
|
||||||
|
parse_lora_syntax,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -189,25 +189,10 @@ class LoraTextLoaderLM:
|
|||||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||||
FUNCTION = "load_loras_from_text"
|
FUNCTION = "load_loras_from_text"
|
||||||
|
|
||||||
def parse_lora_syntax(self, text):
|
|
||||||
"""Parse LoRA syntax from text input."""
|
|
||||||
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
|
|
||||||
matches = re.findall(pattern, text, re.IGNORECASE)
|
|
||||||
|
|
||||||
loras = []
|
|
||||||
for match in matches:
|
|
||||||
model_strength = float(match[1])
|
|
||||||
loras.append({
|
|
||||||
"name": match[0],
|
|
||||||
"model_strength": model_strength,
|
|
||||||
"clip_strength": float(match[2]) if match[2] else model_strength,
|
|
||||||
})
|
|
||||||
return loras
|
|
||||||
|
|
||||||
def load_loras_from_text(self, model, lora_syntax, clip=None, lora_stack=None):
|
def load_loras_from_text(self, model, lora_syntax, clip=None, lora_stack=None):
|
||||||
"""Load LoRAs based on text syntax input."""
|
"""Load LoRAs based on text syntax input."""
|
||||||
lora_entries = _collect_stack_entries(lora_stack)
|
lora_entries = _collect_stack_entries(lora_stack)
|
||||||
for lora in self.parse_lora_syntax(lora_syntax):
|
for lora in parse_lora_syntax(lora_syntax):
|
||||||
lora_path, trigger_words = get_lora_info_absolute(lora["name"])
|
lora_path, trigger_words = get_lora_info_absolute(lora["name"])
|
||||||
lora_entries.append({
|
lora_entries.append({
|
||||||
"name": lora["name"],
|
"name": lora["name"],
|
||||||
|
|||||||
@@ -1,26 +1,102 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_STACK_INPUT_PATTERN = re.compile(r"^lora_stack(?:_([ab])|(\d+))$")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_stack_input(name: str) -> bool:
|
||||||
|
return bool(_STACK_INPUT_PATTERN.match(name))
|
||||||
|
|
||||||
|
|
||||||
|
def _stack_slot_number(name: str) -> int:
|
||||||
|
"""Numeric slot used to order stack inputs; legacy a/b map to 1/2."""
|
||||||
|
match = _STACK_INPUT_PATTERN.match(name)
|
||||||
|
if not match:
|
||||||
|
return -1
|
||||||
|
letter, digits = match.group(1), match.group(2)
|
||||||
|
if digits is not None:
|
||||||
|
return int(digits)
|
||||||
|
return 1 if letter == "a" else 2
|
||||||
|
|
||||||
|
|
||||||
|
class _LoraStackOptionalInputs:
|
||||||
|
"""Lookup that preserves explicit optional inputs and dynamic lora_stack slots."""
|
||||||
|
|
||||||
|
def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None:
|
||||||
|
self._explicit_inputs = explicit_inputs
|
||||||
|
|
||||||
|
def __contains__(self, item: object) -> bool:
|
||||||
|
if not isinstance(item, str):
|
||||||
|
return False
|
||||||
|
return item in self._explicit_inputs or _is_stack_input(item)
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> tuple[str, dict[str, Any]]:
|
||||||
|
if key in self._explicit_inputs:
|
||||||
|
return self._explicit_inputs[key]
|
||||||
|
if _is_stack_input(key):
|
||||||
|
return (
|
||||||
|
"LORA_STACK",
|
||||||
|
{
|
||||||
|
"tooltip": "A LoRA stack to combine. Connect to add more inputs.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise KeyError(key)
|
||||||
|
|
||||||
|
|
||||||
class LoraStackCombinerLM:
|
class LoraStackCombinerLM:
|
||||||
NAME = "Lora Stack Combiner (LoraManager)"
|
NAME = "Lora Stack Combiner (LoraManager)"
|
||||||
CATEGORY = "Lora Manager/stackers"
|
CATEGORY = "Lora Manager/stackers"
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Combines multiple LoRA stacks into a single stack. "
|
||||||
|
"Supports dynamic inputs: connect a stack to add more inputs."
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
return {
|
optional_inputs: dict[str, tuple[str, dict[str, Any]]] = {
|
||||||
"required": {
|
"lora_stack1": (
|
||||||
"lora_stack_a": ("LORA_STACK",),
|
"LORA_STACK",
|
||||||
"lora_stack_b": ("LORA_STACK",),
|
{
|
||||||
|
"tooltip": "A LoRA stack to combine. Connect to add more inputs.",
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
"lora_stack2": (
|
||||||
|
"LORA_STACK",
|
||||||
|
{
|
||||||
|
"tooltip": "A LoRA stack to combine. Connect to add more inputs.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
stack = inspect.stack()
|
||||||
|
if len(stack) > 2 and stack[2].function == "get_input_info":
|
||||||
|
optional_inputs = _LoraStackOptionalInputs(optional_inputs) # type: ignore[assignment]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"required": {},
|
||||||
|
"optional": optional_inputs,
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("LORA_STACK",)
|
RETURN_TYPES = ("LORA_STACK",)
|
||||||
RETURN_NAMES = ("LORA_STACK",)
|
RETURN_NAMES = ("LORA_STACK",)
|
||||||
FUNCTION = "combine_stacks"
|
FUNCTION = "combine_stacks"
|
||||||
|
|
||||||
def combine_stacks(self, lora_stack_a, lora_stack_b):
|
def combine_stacks(self, lora_stack1=None, lora_stack2=None, **kwargs):
|
||||||
combined_stack = []
|
stacks = {
|
||||||
|
"lora_stack1": lora_stack1,
|
||||||
|
"lora_stack2": lora_stack2,
|
||||||
|
}
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if _is_stack_input(key) and value is not None:
|
||||||
|
stacks[key] = value
|
||||||
|
|
||||||
if lora_stack_a:
|
combined_stack = []
|
||||||
combined_stack.extend(lora_stack_a)
|
for key in sorted(stacks, key=_stack_slot_number):
|
||||||
if lora_stack_b:
|
stack = stacks[key]
|
||||||
combined_stack.extend(lora_stack_b)
|
if stack:
|
||||||
|
combined_stack.extend(stack)
|
||||||
|
|
||||||
return (combined_stack,)
|
return (combined_stack,)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Node to resolve `<lora:name:strength>` syntax to absolute file system paths.
|
||||||
|
|
||||||
|
Takes the loaded_loras / active_loras STRING output from LoraLoaderLM or
|
||||||
|
LoraStackerLM and resolves each lora name to its absolute path on disk via
|
||||||
|
the scanner cache. Unknown names are returned as-is.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..utils.utils import get_lora_info_absolute
|
||||||
|
from .utils import parse_lora_syntax
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoraSyntaxToPath:
|
||||||
|
NAME = "LoRA Syntax → Path (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/utils"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"lora_syntax": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"forceInput": True,
|
||||||
|
"multiline": True,
|
||||||
|
"tooltip": (
|
||||||
|
"<lora:name:strength> formatted text from "
|
||||||
|
"loaded_loras / active_loras output"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("STRING",)
|
||||||
|
RETURN_NAMES = ("paths",)
|
||||||
|
FUNCTION = "resolve"
|
||||||
|
|
||||||
|
def resolve(self, lora_syntax: str) -> tuple[str]:
|
||||||
|
"""Parse <lora:...> syntax and resolve each name to its absolute path."""
|
||||||
|
if not lora_syntax or not lora_syntax.strip():
|
||||||
|
logger.info("Received empty lora_syntax input")
|
||||||
|
return ("",)
|
||||||
|
|
||||||
|
parsed = parse_lora_syntax(lora_syntax)
|
||||||
|
if not parsed:
|
||||||
|
logger.info("No valid <lora:...> entries found in input")
|
||||||
|
return ("",)
|
||||||
|
|
||||||
|
paths: list[str] = []
|
||||||
|
for entry in parsed:
|
||||||
|
try:
|
||||||
|
absolute_path, _ = get_lora_info_absolute(entry["name"])
|
||||||
|
paths.append(absolute_path)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to resolve lora '%s', skipping", entry["name"])
|
||||||
|
continue
|
||||||
|
|
||||||
|
return ("\n".join(paths),)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Metadata Overwrite node — allows users to manually specify generation parameters
|
||||||
|
that override the automatically collected/inferred metadata.
|
||||||
|
|
||||||
|
Most inputs have falsy defaults (empty string / 0) which are skipped.
|
||||||
|
clip_skip uses a sentinel default (-25) so that a wired value of 0 is
|
||||||
|
preserved — both ComfyUI and A1111 conventions have no meaningful 0 value,
|
||||||
|
but users may wire 0 to express "no clip skip / default".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..metadata_collector.constants import CLIP_SKIP_SENTINEL as _CLIP_SKIP_SENTINEL
|
||||||
|
from ..metadata_collector.overwrite_utils import collect_overwrite_params
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataOverwriteLM:
|
||||||
|
NAME = "Metadata Overwrite (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/utils"
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Manually specify generation parameters to override automatically collected "
|
||||||
|
"metadata. Only filled/connected inputs will take effect — empty defaults "
|
||||||
|
"are ignored."
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"optional": {
|
||||||
|
"prompt": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"multiline": True,
|
||||||
|
"tooltip": "Positive prompt. Only overwrites when non-empty.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"negative_prompt": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"multiline": True,
|
||||||
|
"tooltip": "Negative prompt. Only overwrites when non-empty.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"seed": (
|
||||||
|
"INT",
|
||||||
|
{
|
||||||
|
"default": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 0xFFFFFFFFFFFFFFFF,
|
||||||
|
"control_after_generate": False,
|
||||||
|
"tooltip": "Seed value. Only overwrites when > 0.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"steps": (
|
||||||
|
"INT",
|
||||||
|
{
|
||||||
|
"default": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 10000,
|
||||||
|
"tooltip": "Number of steps. Only overwrites when > 0.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"cfg_scale": (
|
||||||
|
"FLOAT",
|
||||||
|
{
|
||||||
|
"default": 0.0,
|
||||||
|
"min": 0.0,
|
||||||
|
"max": 100.0,
|
||||||
|
"tooltip": "CFG scale. Only overwrites when > 0.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"sampler": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"tooltip": "Sampler name. Only overwrites when non-empty.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"scheduler": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"tooltip": "Scheduler name. Only overwrites when non-empty.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"model": (
|
||||||
|
"STRING,MODEL",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"widgetType": "STRING",
|
||||||
|
"tooltip": (
|
||||||
|
"The checkpoint or diffusion model (UNet) used "
|
||||||
|
"for generation. Fill in the name manually or "
|
||||||
|
"connect a MODEL output — the model name is then "
|
||||||
|
"extracted automatically. Only overwrites when "
|
||||||
|
"non-empty."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"loras": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"multiline": True,
|
||||||
|
"tooltip": (
|
||||||
|
"LoRA syntax, e.g. <lora:name:strength> "
|
||||||
|
"or <lora:name:model_strength:clip_strength>, "
|
||||||
|
"separated by spaces. Only overwrites when non-empty."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"size": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"tooltip": (
|
||||||
|
"Image size in WIDTHxHEIGHT format (e.g. 512x768). "
|
||||||
|
"Only overwrites when non-empty."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"clip_skip": (
|
||||||
|
"INT",
|
||||||
|
{
|
||||||
|
"default": _CLIP_SKIP_SENTINEL,
|
||||||
|
"min": -25,
|
||||||
|
"max": 24,
|
||||||
|
"tooltip": (
|
||||||
|
"Clip skip (ComfyUI: -24..-1, A1111: 1+). "
|
||||||
|
"Default -25 means not set — any other value "
|
||||||
|
"overwrites."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"additional_data": (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"default": "",
|
||||||
|
"multiline": True,
|
||||||
|
"tooltip": (
|
||||||
|
"Additional data to embed in the image metadata. "
|
||||||
|
"Inserted between Clip skip and Model hash in the "
|
||||||
|
"A1111-compatible parameters string. "
|
||||||
|
'Example: "Copyright": "Some license info"'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("METADATA",)
|
||||||
|
RETURN_NAMES = ("metadata",)
|
||||||
|
FUNCTION = "collect_metadata"
|
||||||
|
OUTPUT_NODE = True
|
||||||
|
|
||||||
|
def collect_metadata(self, **kwargs: Any) -> tuple[dict[str, Any]]:
|
||||||
|
"""Collect non-default input values into a metadata dict.
|
||||||
|
|
||||||
|
For most fields, a falsy value (empty string, 0) means "not set"
|
||||||
|
and is skipped. clip_skip uses a dedicated sentinel (-25) so that
|
||||||
|
a wired value of 0 is preserved and reaches the metadata pipeline.
|
||||||
|
|
||||||
|
The ``model`` field accepts either a manual string or a wired MODEL
|
||||||
|
(ModelPatcher) connection; in the latter case the underlying model
|
||||||
|
name is extracted from the patcher's ``cached_patcher_init`` and
|
||||||
|
stored as a ComfyUI-style relative path.
|
||||||
|
"""
|
||||||
|
return (collect_overwrite_params(kwargs),)
|
||||||
+336
-117
@@ -16,6 +16,156 @@ from PIL import Image, PngImagePlugin
|
|||||||
import piexif
|
import piexif
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
# Civitai-compatible sampler name mapping: ComfyUI internal → A1111 display name
|
||||||
|
CIVITAI_SAMPLER_MAP = {
|
||||||
|
"euler": "Euler",
|
||||||
|
"euler_ancestral": "Euler a",
|
||||||
|
"lms": "LMS",
|
||||||
|
"heun": "Heun",
|
||||||
|
"dpm_2": "DPM2",
|
||||||
|
"dpm_2_ancestral": "DPM2 a",
|
||||||
|
"dpmpp_2s_ancestral": "DPM++ 2S a",
|
||||||
|
"dpmpp_2m": "DPM++ 2M",
|
||||||
|
"dpmpp_sde": "DPM++ SDE",
|
||||||
|
"dpmpp_sde_gpu": "DPM++ SDE",
|
||||||
|
"dpmpp_2m_sde": "DPM++ 2M SDE",
|
||||||
|
"dpmpp_2m_sde_gpu": "DPM++ 2M SDE",
|
||||||
|
"dpmpp_3m_sde": "DPM++ 3M SDE",
|
||||||
|
"dpm_fast": "DPM fast",
|
||||||
|
"dpm_adaptive": "DPM adaptive",
|
||||||
|
"ddim": "DDIM",
|
||||||
|
"plms": "PLMS",
|
||||||
|
"uni_pc_bh2": "UniPC",
|
||||||
|
"uni_pc": "UniPC",
|
||||||
|
"lcm": "LCM",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Base model display name → AIR URN slug
|
||||||
|
# Sourced from civitai source: src/shared/constants/basemodel.constants.ts
|
||||||
|
BASE_MODEL_AIR_SLUG = {
|
||||||
|
# Stable Diffusion family
|
||||||
|
"SD 1.4": "sd1",
|
||||||
|
"SD 1.5": "sd1",
|
||||||
|
"SD 1.5 LCM": "sd1",
|
||||||
|
"SD 1.5 Hyper": "sd1",
|
||||||
|
"SD 2.0": "sd2",
|
||||||
|
"SD 2.0 768": "sd2",
|
||||||
|
"SD 2.1": "sd2",
|
||||||
|
"SD 2.1 768": "sd2",
|
||||||
|
"SD 2.1 Unclip": "sd2",
|
||||||
|
"SD 3.0": "sd3",
|
||||||
|
"SD 3.5": "sd35",
|
||||||
|
"SD 3.5 Large": "sd35",
|
||||||
|
"SD 3.5 Large Turbo": "sd35",
|
||||||
|
"SD 3.5 Medium": "sd35",
|
||||||
|
"SDXL 0.9": "sdxl",
|
||||||
|
"SDXL 1.0": "sdxl",
|
||||||
|
"SDXL 1.0 LCM": "sdxl",
|
||||||
|
"SDXL Lightning": "sdxl",
|
||||||
|
"SDXL Hyper": "sdxl",
|
||||||
|
"SDXL Turbo": "sdxl",
|
||||||
|
"SDXL Distilled": "sdxldistilled",
|
||||||
|
"Stable Cascade": "scascade",
|
||||||
|
"Stable Video Diffusion": "svd",
|
||||||
|
"SVD": "svd",
|
||||||
|
"SVD XT": "svdxt",
|
||||||
|
|
||||||
|
# SDXL community fine-tunes
|
||||||
|
"Pony": "pony",
|
||||||
|
"Pony Diffusion": "pony",
|
||||||
|
"Illustrious": "illustrious",
|
||||||
|
"NoobAI": "noobai",
|
||||||
|
"Animagine": "illustrious",
|
||||||
|
|
||||||
|
# Flux family
|
||||||
|
"Flux.1": "flux1",
|
||||||
|
"Flux.1 D": "flux1",
|
||||||
|
"Flux.1 S": "flux1",
|
||||||
|
"Flux.1 Krea": "fluxkrea",
|
||||||
|
"Flux.1 Kontext": "flux1kontext",
|
||||||
|
"Flux.2": "flux2",
|
||||||
|
"Flux.2 D": "flux2",
|
||||||
|
"Flux.2 Klein 9B": "flux2klein_9b",
|
||||||
|
"Flux.2 Klein 9B Base": "flux2klein_9b_base",
|
||||||
|
"Flux.2 Klein 4B": "flux2klein_4b",
|
||||||
|
"Flux.2 Klein 4B Base": "flux2klein_4b_base",
|
||||||
|
|
||||||
|
# Other image models (sorted alphabetically)
|
||||||
|
"AuraFlow": "auraflow",
|
||||||
|
"Chroma": "chroma",
|
||||||
|
"HiDream": "hidream",
|
||||||
|
"HiDream-O1": "hidream-o1",
|
||||||
|
"Hunyuan DiT": "hydit1",
|
||||||
|
"Hunyuan Video": "hyv1",
|
||||||
|
"Kolors": "kolors",
|
||||||
|
"Lumina": "lumina",
|
||||||
|
"Mochi": "mochi",
|
||||||
|
"ODOR": "odor",
|
||||||
|
"PixArt Alpha": "pixarta",
|
||||||
|
"PixArt Sigma": "pixarte",
|
||||||
|
"Playground v2": "playgroundv2",
|
||||||
|
"Playground v2.5": "playgroundv2",
|
||||||
|
"Pony Diffusion V7": "ponyv7",
|
||||||
|
|
||||||
|
# Video models
|
||||||
|
"CogVideoX": "cogvideox",
|
||||||
|
"LTX Video": "ltxv",
|
||||||
|
"LTX Video 2": "ltxv2",
|
||||||
|
"LTX Video 2.3": "ltxv23",
|
||||||
|
"Wan Video": "wanvideo",
|
||||||
|
"Wan Video 1.3B T2V": "wanvideo_13b_t2v",
|
||||||
|
"Wan Video 14B T2V": "wanvideo_14b_t2v",
|
||||||
|
"Wan Video 14B I2V 480p": "wanvideo_14b_i2v_480p",
|
||||||
|
"Wan Video 14B I2V 720p": "wanvideo_14b_i2v_720p",
|
||||||
|
|
||||||
|
# Third-party / proprietary image models
|
||||||
|
"Boogu": "boogu",
|
||||||
|
"Ernie": "ernie",
|
||||||
|
"Grok": "grok",
|
||||||
|
"HappyHorse": "happyhorse",
|
||||||
|
"Ideogram": "ideogram",
|
||||||
|
"Ideogram 4.0": "ideogram",
|
||||||
|
"Imagen": "imagen4",
|
||||||
|
"Imagen 4": "imagen4",
|
||||||
|
"Krea": "krea2",
|
||||||
|
"Krea 2": "krea2",
|
||||||
|
"Lens": "lens",
|
||||||
|
"MAI": "mai",
|
||||||
|
"Nano Banana": "nanobanana",
|
||||||
|
"OpenAI": "openai",
|
||||||
|
"Reve": "reve",
|
||||||
|
"Reve 2": "reve",
|
||||||
|
"Reve 2.1": "reve",
|
||||||
|
"Seedream": "seedream",
|
||||||
|
"Sora": "sora2",
|
||||||
|
"Sora 2": "sora2",
|
||||||
|
"Veo": "veo3",
|
||||||
|
"Veo 2": "veo3",
|
||||||
|
"Veo 3": "veo3",
|
||||||
|
"ZImageTurbo": "zimageturbo",
|
||||||
|
"ZImageBase": "zimagebase",
|
||||||
|
"ZImage": "zimagebase",
|
||||||
|
|
||||||
|
# Third-party video models
|
||||||
|
"Hailuo by MiniMax": "minimax",
|
||||||
|
"Haiper": "haiper",
|
||||||
|
"Kling": "kling",
|
||||||
|
"Lightricks": "lightricks",
|
||||||
|
"Seedance": "seedance",
|
||||||
|
"Vidu": "vidu",
|
||||||
|
|
||||||
|
# Qwen family
|
||||||
|
"Qwen": "qwen",
|
||||||
|
"Qwen 2": "qwen2",
|
||||||
|
|
||||||
|
# Anima
|
||||||
|
"Anima": "anima",
|
||||||
|
|
||||||
|
# Special
|
||||||
|
"Upscaler": "upscaler",
|
||||||
|
"Other": "other",
|
||||||
|
}
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,11 +220,29 @@ class SaveImageLM:
|
|||||||
"tooltip": "Compression quality for JPEG and lossy WebP formats (1-100). Higher values mean better quality but larger files.",
|
"tooltip": "Compression quality for JPEG and lossy WebP formats (1-100). Higher values mean better quality but larger files.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
"webp_method": (
|
||||||
|
"INT",
|
||||||
|
{
|
||||||
|
"default": 6,
|
||||||
|
"min": 0,
|
||||||
|
"max": 6,
|
||||||
|
"tooltip": "WebP compression method (0-6). 0=fastest/largest, 6=slowest/smallest. Only applies when file_format is 'webp'.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"jpeg_subsampling": (
|
||||||
|
"INT",
|
||||||
|
{
|
||||||
|
"default": 0,
|
||||||
|
"min": 0,
|
||||||
|
"max": 2,
|
||||||
|
"tooltip": "JPEG chroma subsampling level. 0=4:4:4 (best quality), 1=4:2:2, 2=4:2:0 (smallest files). Only applies when file_format is 'jpeg'.",
|
||||||
|
},
|
||||||
|
),
|
||||||
"embed_workflow": (
|
"embed_workflow": (
|
||||||
"BOOLEAN",
|
"BOOLEAN",
|
||||||
{
|
{
|
||||||
"default": False,
|
"default": False,
|
||||||
"tooltip": "Embeds the complete workflow data into the image metadata. Only works with PNG and WebP formats.",
|
"tooltip": "When enabled, saved images store the complete workflow. Drag the image back into ComfyUI to restore the original node graph. PNG and WebP only.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
"save_with_metadata": (
|
"save_with_metadata": (
|
||||||
@@ -142,148 +310,194 @@ class SaveImageLM:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def format_metadata(self, metadata_dict):
|
def _resolve_model_cache_entry(self, scanner_type: str, name: str):
|
||||||
"""Format metadata in the requested format similar to userComment example"""
|
"""Resolve model hash, civitai metadata, and base_model from scanner cache.
|
||||||
if not metadata_dict:
|
Returns (hash_str, civitai_dict, base_model_str). All values are empty defaults when not found."""
|
||||||
return ""
|
scanner = ServiceRegistry.get_service_sync(scanner_type)
|
||||||
|
if scanner is None or not name:
|
||||||
|
return "", {}, ""
|
||||||
|
|
||||||
# Helper function to only add parameter if value is not None
|
entry = self._get_cached_model_by_name(scanner, name)
|
||||||
def add_param_if_not_none(param_list, label, value):
|
if entry is None:
|
||||||
if value is not None:
|
basename = os.path.splitext(os.path.basename(name))[0]
|
||||||
param_list.append(f"{label}: {value}")
|
hash_val = scanner.get_hash_by_filename(basename)
|
||||||
|
return (hash_val or "").lower(), {}, ""
|
||||||
|
|
||||||
|
hash_val = (entry.get("sha256") or "").lower()
|
||||||
|
civitai = entry.get("civitai") or {}
|
||||||
|
base_model = entry.get("base_model") or ""
|
||||||
|
return hash_val, civitai, base_model
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_civitai_sampler_name(sampler_name: str, scheduler: str) -> str:
|
||||||
|
if sampler_name in CIVITAI_SAMPLER_MAP:
|
||||||
|
civitai_name = CIVITAI_SAMPLER_MAP[sampler_name]
|
||||||
|
if scheduler == "karras":
|
||||||
|
civitai_name += " Karras"
|
||||||
|
elif scheduler == "exponential":
|
||||||
|
civitai_name += " Exponential"
|
||||||
|
return civitai_name
|
||||||
|
else:
|
||||||
|
if scheduler and scheduler != "normal":
|
||||||
|
return f"{sampler_name}_{scheduler}"
|
||||||
|
return sampler_name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_air_string(base_model: str, model_type: str, model_id: int, version_id: int) -> str:
|
||||||
|
slug = BASE_MODEL_AIR_SLUG.get(base_model, "other")
|
||||||
|
type_lower = model_type.lower() if model_type else "other"
|
||||||
|
return f"urn:air:{slug}:{type_lower}:civitai:{model_id}@{version_id}"
|
||||||
|
|
||||||
|
def format_metadata(self, metadata_dict: dict) -> str:
|
||||||
|
"""Format metadata as A1111-compatible parameters string with Hashes JSON and Civitai resources."""
|
||||||
|
if not metadata_dict: return ""
|
||||||
|
|
||||||
# Extract the prompt and negative prompt
|
|
||||||
prompt = metadata_dict.get("prompt", "")
|
prompt = metadata_dict.get("prompt", "")
|
||||||
negative_prompt = metadata_dict.get("negative_prompt", "")
|
negative_prompt = metadata_dict.get("negative_prompt", "")
|
||||||
|
steps = metadata_dict.get("steps")
|
||||||
# Extract loras from the prompt if present
|
cfg = metadata_dict.get("guidance")
|
||||||
|
if cfg is None:
|
||||||
|
cfg = metadata_dict.get("cfg_scale")
|
||||||
|
if cfg is None:
|
||||||
|
cfg = metadata_dict.get("cfg")
|
||||||
|
seed = metadata_dict.get("seed")
|
||||||
|
size = metadata_dict.get("size")
|
||||||
|
sampler = metadata_dict.get("sampler") or ""
|
||||||
|
scheduler = metadata_dict.get("scheduler") or "normal"
|
||||||
|
checkpoint = metadata_dict.get("checkpoint") or ""
|
||||||
loras_text = metadata_dict.get("loras", "")
|
loras_text = metadata_dict.get("loras", "")
|
||||||
lora_hashes = {}
|
clip_skip = metadata_dict.get("clip_skip")
|
||||||
|
|
||||||
# If loras are found, add them on a new line after the prompt
|
# Parse LoRA entries from <lora:name:strength> format
|
||||||
|
lora_entries: list[tuple[str, float]] = []
|
||||||
if loras_text:
|
if loras_text:
|
||||||
prompt_with_loras = f"{prompt}\n{loras_text}"
|
for match in re.findall(r"<lora:([^:]+):([^>]+)>", loras_text):
|
||||||
|
lora_name, strength_str = match
|
||||||
|
try:
|
||||||
|
strength = float(strength_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
strength = 1.0
|
||||||
|
lora_entries.append((lora_name, strength))
|
||||||
|
|
||||||
# Extract lora names from the format <lora:name:strength>
|
# Resolve checkpoint hash and Civitai data from local cache
|
||||||
lora_matches = re.findall(r"<lora:([^:]+):([^>]+)>", loras_text)
|
ckpt_hash, ckpt_civitai, ckpt_base_model = "", {}, ""
|
||||||
|
ckpt_display_name = ""
|
||||||
|
if checkpoint:
|
||||||
|
ckpt_hash, ckpt_civitai, ckpt_base_model = self._resolve_model_cache_entry(
|
||||||
|
"checkpoint_scanner", checkpoint
|
||||||
|
)
|
||||||
|
ckpt_display_name = os.path.splitext(os.path.basename(checkpoint))[0]
|
||||||
|
|
||||||
# Get hash for each lora
|
# Resolve LoRA hash and Civitai data from local cache
|
||||||
for lora_name, strength in lora_matches:
|
loras_data: list[dict] = []
|
||||||
hash_value = self.get_lora_hash(lora_name)
|
for lora_name, strength in lora_entries:
|
||||||
if hash_value:
|
lora_hash, lora_civitai, lora_base_model = self._resolve_model_cache_entry(
|
||||||
lora_hashes[lora_name] = hash_value
|
"lora_scanner", lora_name
|
||||||
else:
|
)
|
||||||
prompt_with_loras = prompt
|
loras_data.append({
|
||||||
|
"name": lora_name,
|
||||||
|
"strength": strength,
|
||||||
|
"hash": lora_hash,
|
||||||
|
"civitai": lora_civitai,
|
||||||
|
"base_model": lora_base_model,
|
||||||
|
})
|
||||||
|
|
||||||
# Format the first part (prompt and loras)
|
# Build Hashes JSON (A1111 / Civitai standard format)
|
||||||
metadata_parts = [prompt_with_loras]
|
hashes: dict[str, str] = {}
|
||||||
|
if ckpt_hash:
|
||||||
|
hashes["model"] = ckpt_hash[:10].upper()
|
||||||
|
for lora in loras_data:
|
||||||
|
if lora["hash"]:
|
||||||
|
hashes[f"LORA:{lora['name']}"] = lora["hash"][:10].upper()
|
||||||
|
|
||||||
# Add negative prompt
|
# Build Civitai resources JSON array
|
||||||
if negative_prompt:
|
civitai_resources: list[dict] = []
|
||||||
metadata_parts.append(f"Negative prompt: {negative_prompt}")
|
if ckpt_civitai.get("id", 0) > 0:
|
||||||
|
ckpt_resource: dict = {}
|
||||||
|
ckpt_type = (ckpt_civitai.get("model") or {}).get("type", "Checkpoint")
|
||||||
|
model_id = ckpt_civitai.get("modelId", 0)
|
||||||
|
version_id = ckpt_civitai.get("id", 0)
|
||||||
|
if model_id and version_id:
|
||||||
|
ckpt_resource["air"] = self._build_air_string(
|
||||||
|
ckpt_base_model, ckpt_type, int(model_id), int(version_id)
|
||||||
|
)
|
||||||
|
elif version_id:
|
||||||
|
ckpt_resource["modelVersionId"] = int(version_id)
|
||||||
|
if ckpt_civitai.get("name"):
|
||||||
|
ckpt_resource["versionName"] = ckpt_civitai["name"]
|
||||||
|
if ckpt_resource:
|
||||||
|
civitai_resources.append(ckpt_resource)
|
||||||
|
|
||||||
# Format the second part (generation parameters)
|
for lora in loras_data:
|
||||||
params = []
|
lora_civitai = lora["civitai"]
|
||||||
|
if not lora_civitai or lora_civitai.get("id", 0) <= 0:
|
||||||
|
continue
|
||||||
|
lora_resource: dict = {"weight": lora["strength"]}
|
||||||
|
lora_type = (lora_civitai.get("model") or {}).get("type", "LORA")
|
||||||
|
model_id = lora_civitai.get("modelId", 0)
|
||||||
|
version_id = lora_civitai.get("id", 0)
|
||||||
|
if model_id and version_id:
|
||||||
|
lora_resource["air"] = self._build_air_string(
|
||||||
|
lora["base_model"], lora_type, int(model_id), int(version_id)
|
||||||
|
)
|
||||||
|
elif version_id:
|
||||||
|
lora_resource["modelVersionId"] = int(version_id)
|
||||||
|
if lora_civitai.get("name"):
|
||||||
|
lora_resource["versionName"] = lora_civitai["name"]
|
||||||
|
civitai_resources.append(lora_resource)
|
||||||
|
|
||||||
# Add standard parameters in the correct order
|
sampler_name = CIVITAI_SAMPLER_MAP.get(sampler, sampler) if sampler else None
|
||||||
if "steps" in metadata_dict:
|
|
||||||
add_param_if_not_none(params, "Steps", metadata_dict.get("steps"))
|
|
||||||
|
|
||||||
# Combine sampler and scheduler information
|
|
||||||
sampler_name = None
|
|
||||||
scheduler_name = None
|
|
||||||
|
|
||||||
if "sampler" in metadata_dict:
|
|
||||||
sampler = metadata_dict.get("sampler")
|
|
||||||
# Convert ComfyUI sampler names to user-friendly names
|
|
||||||
sampler_mapping = {
|
|
||||||
"euler": "Euler",
|
|
||||||
"euler_ancestral": "Euler a",
|
|
||||||
"dpm_2": "DPM2",
|
|
||||||
"dpm_2_ancestral": "DPM2 a",
|
|
||||||
"heun": "Heun",
|
|
||||||
"dpm_fast": "DPM fast",
|
|
||||||
"dpm_adaptive": "DPM adaptive",
|
|
||||||
"lms": "LMS",
|
|
||||||
"dpmpp_2s_ancestral": "DPM++ 2S a",
|
|
||||||
"dpmpp_sde": "DPM++ SDE",
|
|
||||||
"dpmpp_sde_gpu": "DPM++ SDE",
|
|
||||||
"dpmpp_2m": "DPM++ 2M",
|
|
||||||
"dpmpp_2m_sde": "DPM++ 2M SDE",
|
|
||||||
"dpmpp_2m_sde_gpu": "DPM++ 2M SDE",
|
|
||||||
"ddim": "DDIM",
|
|
||||||
}
|
|
||||||
sampler_name = sampler_mapping.get(sampler, sampler)
|
|
||||||
|
|
||||||
if "scheduler" in metadata_dict:
|
|
||||||
scheduler = metadata_dict.get("scheduler")
|
|
||||||
scheduler_mapping = {
|
scheduler_mapping = {
|
||||||
"normal": "Simple",
|
"normal": "Normal",
|
||||||
"karras": "Karras",
|
"karras": "Karras",
|
||||||
"exponential": "Exponential",
|
"exponential": "Exponential",
|
||||||
"sgm_uniform": "SGM Uniform",
|
"sgm_uniform": "SGM Uniform",
|
||||||
"sgm_quadratic": "SGM Quadratic",
|
"sgm_quadratic": "SGM Quadratic",
|
||||||
}
|
}
|
||||||
scheduler_name = scheduler_mapping.get(scheduler, scheduler)
|
scheduler_name = scheduler_mapping.get(scheduler, scheduler) if scheduler else None
|
||||||
|
|
||||||
# Add combined sampler and scheduler information
|
# Build output lines
|
||||||
|
lines = [prompt] if prompt else [""]
|
||||||
|
if negative_prompt:
|
||||||
|
lines.append(f"Negative prompt: {negative_prompt}")
|
||||||
|
|
||||||
|
params: list[str] = []
|
||||||
|
if steps is not None:
|
||||||
|
params.append(f"Steps: {steps}")
|
||||||
if sampler_name:
|
if sampler_name:
|
||||||
if scheduler_name:
|
if scheduler_name:
|
||||||
params.append(f"Sampler: {sampler_name} {scheduler_name}")
|
params.append(f"Sampler: {sampler_name} {scheduler_name}")
|
||||||
else:
|
else:
|
||||||
params.append(f"Sampler: {sampler_name}")
|
params.append(f"Sampler: {sampler_name}")
|
||||||
|
if cfg is not None:
|
||||||
# CFG scale (Use guidance if available, otherwise fall back to cfg_scale or cfg)
|
params.append(f"CFG scale: {cfg}")
|
||||||
if "guidance" in metadata_dict:
|
if seed is not None:
|
||||||
add_param_if_not_none(params, "CFG scale", metadata_dict.get("guidance"))
|
params.append(f"Seed: {seed}")
|
||||||
elif "cfg_scale" in metadata_dict:
|
if size:
|
||||||
add_param_if_not_none(params, "CFG scale", metadata_dict.get("cfg_scale"))
|
params.append(f"Size: {size}")
|
||||||
elif "cfg" in metadata_dict:
|
if clip_skip is not None:
|
||||||
add_param_if_not_none(params, "CFG scale", metadata_dict.get("cfg"))
|
try:
|
||||||
|
params.append(f"Clip skip: {abs(int(clip_skip))}")
|
||||||
# Seed
|
except (ValueError, TypeError):
|
||||||
if "seed" in metadata_dict:
|
pass
|
||||||
add_param_if_not_none(params, "Seed", metadata_dict.get("seed"))
|
additional_data = metadata_dict.get("additional_data", "")
|
||||||
|
if additional_data:
|
||||||
# Size
|
params.append(additional_data)
|
||||||
if "size" in metadata_dict:
|
if ckpt_hash:
|
||||||
add_param_if_not_none(params, "Size", metadata_dict.get("size"))
|
params.append(f"Model hash: {ckpt_hash[:10].upper()}")
|
||||||
|
if ckpt_display_name:
|
||||||
# Model info
|
params.append(f"Model: {ckpt_display_name}")
|
||||||
if "checkpoint" in metadata_dict:
|
if hashes:
|
||||||
# Ensure checkpoint is a string before processing
|
params.append(f"Hashes: {json.dumps(hashes, separators=(',', ':'))}")
|
||||||
checkpoint = metadata_dict.get("checkpoint")
|
params.append("Version: ComfyUI")
|
||||||
if checkpoint is not None:
|
if civitai_resources:
|
||||||
# Get model hash
|
|
||||||
model_hash = self.get_checkpoint_hash(checkpoint)
|
|
||||||
|
|
||||||
# Extract basename without path
|
|
||||||
checkpoint_name = os.path.basename(checkpoint)
|
|
||||||
# Remove extension if present
|
|
||||||
checkpoint_name = os.path.splitext(checkpoint_name)[0]
|
|
||||||
|
|
||||||
# Add model hash if available
|
|
||||||
if model_hash:
|
|
||||||
params.append(
|
params.append(
|
||||||
f"Model hash: {model_hash[:10]}, Model: {checkpoint_name}"
|
f"Civitai resources: {json.dumps(civitai_resources, separators=(',', ':'))}"
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
params.append(f"Model: {checkpoint_name}")
|
|
||||||
|
|
||||||
# Add LoRA hashes if available
|
lines.append(", ".join(params))
|
||||||
if lora_hashes:
|
return "\n".join(lines)
|
||||||
lora_hash_parts = []
|
|
||||||
for lora_name, hash_value in lora_hashes.items():
|
|
||||||
lora_hash_parts.append(f"{lora_name}: {hash_value[:10]}")
|
|
||||||
|
|
||||||
if lora_hash_parts:
|
|
||||||
params.append(f'Lora hashes: "{", ".join(lora_hash_parts)}"')
|
|
||||||
|
|
||||||
# Combine all parameters with commas
|
|
||||||
metadata_parts.append(", ".join(params))
|
|
||||||
|
|
||||||
# Join all parts with a new line
|
|
||||||
return "\n".join(metadata_parts)
|
|
||||||
|
|
||||||
# credit to nkchocoai
|
# credit to nkchocoai
|
||||||
# Add format_filename method to handle pattern substitution
|
# Add format_filename method to handle pattern substitution
|
||||||
@@ -573,6 +787,8 @@ class SaveImageLM:
|
|||||||
extra_pnginfo=None,
|
extra_pnginfo=None,
|
||||||
lossless_webp=True,
|
lossless_webp=True,
|
||||||
quality=100,
|
quality=100,
|
||||||
|
webp_method=6,
|
||||||
|
jpeg_subsampling=0,
|
||||||
embed_workflow=False,
|
embed_workflow=False,
|
||||||
save_with_metadata=True,
|
save_with_metadata=True,
|
||||||
add_counter_to_filename=True,
|
add_counter_to_filename=True,
|
||||||
@@ -627,15 +843,14 @@ class SaveImageLM:
|
|||||||
elif file_format == "jpeg":
|
elif file_format == "jpeg":
|
||||||
file = base_filename + ".jpg"
|
file = base_filename + ".jpg"
|
||||||
file_extension = ".jpg"
|
file_extension = ".jpg"
|
||||||
save_kwargs = {"quality": quality, "optimize": True}
|
save_kwargs = {"quality": quality, "optimize": True, "subsampling": jpeg_subsampling}
|
||||||
elif file_format == "webp":
|
elif file_format == "webp":
|
||||||
file = base_filename + ".webp"
|
file = base_filename + ".webp"
|
||||||
file_extension = ".webp"
|
file_extension = ".webp"
|
||||||
# Add optimization param to control performance
|
|
||||||
save_kwargs = {
|
save_kwargs = {
|
||||||
"quality": quality,
|
"quality": quality,
|
||||||
"lossless": lossless_webp,
|
"lossless": lossless_webp,
|
||||||
"method": 0,
|
"method": webp_method,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported file format: {file_format}")
|
raise ValueError(f"Unsupported file format: {file_format}")
|
||||||
@@ -722,6 +937,8 @@ class SaveImageLM:
|
|||||||
extra_pnginfo=None,
|
extra_pnginfo=None,
|
||||||
lossless_webp=True,
|
lossless_webp=True,
|
||||||
quality=100,
|
quality=100,
|
||||||
|
webp_method=6,
|
||||||
|
jpeg_subsampling=0,
|
||||||
embed_workflow=False,
|
embed_workflow=False,
|
||||||
save_with_metadata=True,
|
save_with_metadata=True,
|
||||||
add_counter_to_filename=True,
|
add_counter_to_filename=True,
|
||||||
@@ -751,6 +968,8 @@ class SaveImageLM:
|
|||||||
extra_pnginfo,
|
extra_pnginfo,
|
||||||
lossless_webp,
|
lossless_webp,
|
||||||
quality,
|
quality,
|
||||||
|
webp_method,
|
||||||
|
jpeg_subsampling,
|
||||||
embed_workflow,
|
embed_workflow,
|
||||||
save_with_metadata,
|
save_with_metadata,
|
||||||
add_counter_to_filename,
|
add_counter_to_filename,
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_c
|
|||||||
logger = logging.getLogger(__name__)
|
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 UNETLoaderLM.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 = UNETLoaderLM()
|
||||||
|
model, = loader._load_gguf_unet(unet_path, unet_path, weight_dtype)
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
class UNETLoaderLM:
|
class UNETLoaderLM:
|
||||||
"""UNET Loader with support for extra folder paths
|
"""UNET Loader with support for extra folder paths
|
||||||
|
|
||||||
@@ -196,6 +211,12 @@ class UNETLoaderLM:
|
|||||||
# Wrap with GGUFModelPatcher
|
# Wrap with GGUFModelPatcher
|
||||||
model = GGUFModelPatcher.clone(model)
|
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,)
|
return (model,)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ any_type = AnyType("*")
|
|||||||
|
|
||||||
# Common methods extracted from lora_loader.py and lora_stacker.py
|
# Common methods extracted from lora_loader.py and lora_stacker.py
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import logging
|
import logging
|
||||||
import copy
|
import copy
|
||||||
import sys
|
import sys
|
||||||
@@ -69,6 +70,25 @@ 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]:
|
||||||
|
"""Parse <lora:name:strength> syntax from text input into a list of dicts.
|
||||||
|
|
||||||
|
Each entry contains: name, model_strength, clip_strength.
|
||||||
|
Supports both ``<lora:name:strength>`` and ``<lora:name:model_strength:clip_strength>``.
|
||||||
|
"""
|
||||||
|
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
|
||||||
|
matches = re.findall(pattern, text, re.IGNORECASE)
|
||||||
|
loras = []
|
||||||
|
for match in matches:
|
||||||
|
model_strength = float(match[1])
|
||||||
|
loras.append({
|
||||||
|
"name": match[0],
|
||||||
|
"model_strength": model_strength,
|
||||||
|
"clip_strength": float(match[2]) if match[2] else model_strength,
|
||||||
|
})
|
||||||
|
return loras
|
||||||
|
|
||||||
|
|
||||||
def get_loras_list(kwargs):
|
def get_loras_list(kwargs):
|
||||||
"""Helper to extract loras list from either old or new kwargs format"""
|
"""Helper to extract loras list from either old or new kwargs format"""
|
||||||
if "loras" not in kwargs:
|
if "loras" not in kwargs:
|
||||||
|
|||||||
@@ -1562,6 +1562,11 @@ class SettingsHandler:
|
|||||||
{"success": False, "error": validation_error}
|
{"success": False, "error": validation_error}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if key == "update_channel" and value not in ("release", "nightly"):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "update_channel must be 'release' or 'nightly'"}
|
||||||
|
)
|
||||||
|
|
||||||
if value == "__DELETE__" and key in (
|
if value == "__DELETE__" and key in (
|
||||||
"proxy_username",
|
"proxy_username",
|
||||||
"proxy_password",
|
"proxy_password",
|
||||||
@@ -1570,7 +1575,11 @@ class SettingsHandler:
|
|||||||
else:
|
else:
|
||||||
self._settings.set(key, value)
|
self._settings.set(key, value)
|
||||||
|
|
||||||
if key == "enable_metadata_archive_db":
|
if key in (
|
||||||
|
"enable_metadata_archive_db",
|
||||||
|
"enable_civarchive_api",
|
||||||
|
"metadata_provider_order",
|
||||||
|
):
|
||||||
await self._metadata_provider_updater()
|
await self._metadata_provider_updater()
|
||||||
|
|
||||||
if key in self._PROXY_KEYS:
|
if key in self._PROXY_KEYS:
|
||||||
@@ -2581,6 +2590,8 @@ class ModelLibraryHandler:
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cursor = request.query.get("cursor")
|
||||||
|
|
||||||
metadata_provider = await self._metadata_provider_factory()
|
metadata_provider = await self._metadata_provider_factory()
|
||||||
if not metadata_provider:
|
if not metadata_provider:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -2589,7 +2600,7 @@ class ModelLibraryHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
models = await metadata_provider.get_user_models(username)
|
result = await metadata_provider.get_user_models(username, cursor)
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
@@ -2599,14 +2610,35 @@ class ModelLibraryHandler:
|
|||||||
status=501,
|
status=501,
|
||||||
)
|
)
|
||||||
|
|
||||||
if models is None:
|
if result is None:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Failed to fetch user models"},
|
{"success": False, "error": "Failed to fetch user models"},
|
||||||
status=502,
|
status=502,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if isinstance(result, dict):
|
||||||
|
models = result.get("items")
|
||||||
|
next_cursor = result.get("nextCursor")
|
||||||
|
else:
|
||||||
|
# Defensive: tolerate providers that still return a raw list
|
||||||
|
models = result
|
||||||
|
next_cursor = None
|
||||||
|
|
||||||
if not isinstance(models, list):
|
if not isinstance(models, list):
|
||||||
models = []
|
models = []
|
||||||
|
if next_cursor is not None and not isinstance(next_cursor, str):
|
||||||
|
next_cursor = str(next_cursor)
|
||||||
|
|
||||||
|
estimated_total = None
|
||||||
|
if cursor is None:
|
||||||
|
get_count = getattr(metadata_provider, "get_creator_model_count", None)
|
||||||
|
if get_count is not None:
|
||||||
|
try:
|
||||||
|
estimated_total = await get_count(username)
|
||||||
|
except Exception: # best-effort only
|
||||||
|
estimated_total = None
|
||||||
|
if not isinstance(estimated_total, int):
|
||||||
|
estimated_total = None
|
||||||
|
|
||||||
lora_scanner = await self._service_registry.get_lora_scanner()
|
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||||
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
@@ -2626,6 +2658,7 @@ class ModelLibraryHandler:
|
|||||||
versions: list[dict] = []
|
versions: list[dict] = []
|
||||||
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
|
||||||
for model in models:
|
for model in models:
|
||||||
try:
|
try:
|
||||||
model_ids.append(int(model.get("id")))
|
model_ids.append(int(model.get("id")))
|
||||||
@@ -2659,6 +2692,8 @@ class ModelLibraryHandler:
|
|||||||
if model_type not in normalized_allowed_types:
|
if model_type not in normalized_allowed_types:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
model_count += 1
|
||||||
|
|
||||||
scanner = type_scanner_map.get(model_type)
|
scanner = type_scanner_map.get(model_type)
|
||||||
if scanner is None:
|
if scanner is None:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -2724,7 +2759,15 @@ class ModelLibraryHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": True, "username": username, "versions": versions}
|
{
|
||||||
|
"success": True,
|
||||||
|
"username": username,
|
||||||
|
"versions": versions,
|
||||||
|
"modelCount": model_count,
|
||||||
|
"nextCursor": next_cursor,
|
||||||
|
"hasMore": next_cursor is not None,
|
||||||
|
"estimatedTotal": estimated_total,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Failed to get Civitai user models: %s", exc, exc_info=True)
|
logger.error("Failed to get Civitai user models: %s", exc, exc_info=True)
|
||||||
@@ -3471,7 +3514,7 @@ class NodeRegistryHandler:
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not isinstance(value, str) or not value:
|
if value is None or (isinstance(value, str) and not value):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Missing value parameter"}, status=400
|
{"success": False, "error": "Missing value parameter"}, status=400
|
||||||
)
|
)
|
||||||
@@ -3578,7 +3621,7 @@ class NodeRegistryHandler:
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not isinstance(value, str) or not value:
|
if value is None or (isinstance(value, str) and not value):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Missing value parameter"}, status=400
|
{"success": False, "error": "Missing value parameter"}, status=400
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -394,12 +394,14 @@ class ModelListingHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# View-local-versions filter: show all local versions of a specific model
|
# View-local-versions filter: show all local versions of a specific model
|
||||||
|
# Accepts either a CivitAI modelId (int) or a HF group key like "hf:user/repo"
|
||||||
civitai_model_id = request.query.get("civitai_model_id")
|
civitai_model_id = request.query.get("civitai_model_id")
|
||||||
if civitai_model_id is not None:
|
if civitai_model_id is not None:
|
||||||
try:
|
try:
|
||||||
civitai_model_id = int(civitai_model_id)
|
civitai_model_id = int(civitai_model_id)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
civitai_model_id = None
|
# Keep as string — could be an HF group key (e.g. "hf:user/repo")
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"page": page,
|
"page": page,
|
||||||
@@ -537,6 +539,7 @@ class ModelManagementHandler:
|
|||||||
# Update model_data with new hash
|
# Update model_data with new hash
|
||||||
model_data["sha256"] = sha256
|
model_data["sha256"] = sha256
|
||||||
model_data["hash_status"] = "completed"
|
model_data["hash_status"] = "completed"
|
||||||
|
hash_status = "completed"
|
||||||
else:
|
else:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "No SHA256 hash found"}, status=400
|
{"success": False, "error": "No SHA256 hash found"}, status=400
|
||||||
@@ -544,6 +547,32 @@ class ModelManagementHandler:
|
|||||||
|
|
||||||
await MetadataManager.hydrate_model_data(model_data)
|
await MetadataManager.hydrate_model_data(model_data)
|
||||||
|
|
||||||
|
# hydrate_model_data replaces model_data with .metadata.json content,
|
||||||
|
# which may lack sha256. Restore from cache and persist the fix.
|
||||||
|
if not model_data.get("sha256"):
|
||||||
|
if sha256:
|
||||||
|
model_data["sha256"] = sha256
|
||||||
|
model_data["hash_status"] = model_data.get("hash_status", hash_status)
|
||||||
|
data_to_save = model_data.copy()
|
||||||
|
data_to_save.pop("folder", None)
|
||||||
|
await MetadataManager.save_metadata(file_path, data_to_save)
|
||||||
|
else:
|
||||||
|
sha256 = await calculate_sha256(file_path)
|
||||||
|
if sha256:
|
||||||
|
model_data["sha256"] = sha256.lower()
|
||||||
|
model_data["hash_status"] = "completed"
|
||||||
|
data_to_save = model_data.copy()
|
||||||
|
data_to_save.pop("folder", None)
|
||||||
|
await MetadataManager.save_metadata(file_path, data_to_save)
|
||||||
|
else:
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Failed to compute SHA256 hash for model",
|
||||||
|
},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
|
||||||
success, error = await self._metadata_sync.fetch_and_update_model(
|
success, error = await self._metadata_sync.fetch_and_update_model(
|
||||||
sha256=model_data["sha256"],
|
sha256=model_data["sha256"],
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
@@ -566,7 +595,12 @@ class ModelManagementHandler:
|
|||||||
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
|
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
|
||||||
status=503,
|
status=503,
|
||||||
)
|
)
|
||||||
self._logger.error("Error fetching from CivitAI: %s", exc, exc_info=True)
|
self._logger.error(
|
||||||
|
"Error fetching from CivitAI for %s: %s",
|
||||||
|
locals().get("file_path", "unknown"),
|
||||||
|
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 relink_civitai(self, request: web.Request) -> web.Response:
|
async def relink_civitai(self, request: web.Request) -> web.Response:
|
||||||
@@ -973,6 +1007,8 @@ class ModelQueryHandler:
|
|||||||
limit = int(request.query.get("limit", "20"))
|
limit = int(request.query.get("limit", "20"))
|
||||||
if limit < 0:
|
if limit < 0:
|
||||||
limit = 20
|
limit = 20
|
||||||
|
elif limit > 200:
|
||||||
|
limit = 20
|
||||||
top_tags = await self._service.get_top_tags(limit)
|
top_tags = await self._service.get_top_tags(limit)
|
||||||
return web.json_response({"success": True, "tags": top_tags})
|
return web.json_response({"success": True, "tags": top_tags})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -981,6 +1017,22 @@ class ModelQueryHandler:
|
|||||||
{"success": False, "error": "Internal server error"}, status=500
|
{"success": False, "error": "Internal server error"}, status=500
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def search_tags(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
query = request.query.get("q", "")
|
||||||
|
limit = int(request.query.get("limit", "20"))
|
||||||
|
if limit < 0:
|
||||||
|
limit = 20
|
||||||
|
elif limit > 200:
|
||||||
|
limit = 20
|
||||||
|
tags = await self._service.search_tags(query, limit)
|
||||||
|
return web.json_response({"success": True, "tags": tags})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error searching tags: %s", exc, exc_info=True)
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Internal server error"}, status=500
|
||||||
|
)
|
||||||
|
|
||||||
async def get_base_models(self, request: web.Request) -> web.Response:
|
async def get_base_models(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
limit = int(request.query.get("limit", "20"))
|
limit = int(request.query.get("limit", "20"))
|
||||||
@@ -1787,14 +1839,20 @@ class ModelDownloadHandler:
|
|||||||
|
|
||||||
async def delete_download_history_item(self, request: web.Request) -> web.Response:
|
async def delete_download_history_item(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
item_id = int(request.query.get("id", "0"))
|
download_id = request.query.get("download_id")
|
||||||
if not item_id:
|
id_str = request.query.get("id")
|
||||||
|
item_id = int(id_str) if id_str else None
|
||||||
|
|
||||||
|
if not download_id and not item_id:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "id is required"}, status=400
|
{"success": False, "error": "id or download_id is required"},
|
||||||
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
service = await DownloadQueueService.get_instance()
|
service = await DownloadQueueService.get_instance()
|
||||||
deleted = await service.delete_history_item(item_id)
|
deleted = await service.delete_history_item(
|
||||||
|
id=item_id, download_id=download_id
|
||||||
|
)
|
||||||
return web.json_response({"success": deleted})
|
return web.json_response({"success": deleted})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
@@ -1804,14 +1862,20 @@ class ModelDownloadHandler:
|
|||||||
|
|
||||||
async def retry_download_from_history(self, request: web.Request) -> web.Response:
|
async def retry_download_from_history(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
item_id = int(request.query.get("id", "0"))
|
download_id = request.query.get("download_id")
|
||||||
if not item_id:
|
id_str = request.query.get("id")
|
||||||
|
item_id = int(id_str) if id_str else None
|
||||||
|
|
||||||
|
if not download_id and not item_id:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "id is required"}, status=400
|
{"success": False, "error": "id or download_id is required"},
|
||||||
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
service = await DownloadQueueService.get_instance()
|
service = await DownloadQueueService.get_instance()
|
||||||
item = await service.retry_from_history(item_id)
|
item = await service.retry_from_history(
|
||||||
|
item_id=item_id, download_id=download_id
|
||||||
|
)
|
||||||
if item is None:
|
if item is None:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "History item not found or not retryable"},
|
{"success": False, "error": "History item not found or not retryable"},
|
||||||
@@ -2935,6 +2999,7 @@ class ModelHandlerSet:
|
|||||||
"bulk_delete_models": self.management.bulk_delete_models,
|
"bulk_delete_models": self.management.bulk_delete_models,
|
||||||
"verify_duplicates": self.management.verify_duplicates,
|
"verify_duplicates": self.management.verify_duplicates,
|
||||||
"get_top_tags": self.query.get_top_tags,
|
"get_top_tags": self.query.get_top_tags,
|
||||||
|
"search_tags": self.query.search_tags,
|
||||||
"get_base_models": self.query.get_base_models,
|
"get_base_models": self.query.get_base_models,
|
||||||
"get_model_types": self.query.get_model_types,
|
"get_model_types": self.query.get_model_types,
|
||||||
"scan_models": self.query.scan_models,
|
"scan_models": self.query.scan_models,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class RecipeHandlerSet:
|
|||||||
"save_recipe": self.management.save_recipe,
|
"save_recipe": self.management.save_recipe,
|
||||||
"delete_recipe": self.management.delete_recipe,
|
"delete_recipe": self.management.delete_recipe,
|
||||||
"get_top_tags": self.query.get_top_tags,
|
"get_top_tags": self.query.get_top_tags,
|
||||||
|
"search_tags": self.query.search_tags,
|
||||||
"get_base_models": self.query.get_base_models,
|
"get_base_models": self.query.get_base_models,
|
||||||
"get_roots": self.query.get_roots,
|
"get_roots": self.query.get_roots,
|
||||||
"get_folders": self.query.get_folders,
|
"get_folders": self.query.get_folders,
|
||||||
@@ -317,12 +318,11 @@ class RecipeQueryHandler:
|
|||||||
raise RuntimeError("Recipe scanner unavailable")
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
limit = int(request.query.get("limit", "20"))
|
limit = int(request.query.get("limit", "20"))
|
||||||
cache = await recipe_scanner.get_cached_data()
|
if limit < 0:
|
||||||
|
limit = 20
|
||||||
tag_counts: Dict[str, int] = {}
|
elif limit > 200:
|
||||||
for recipe in getattr(cache, "raw_data", []):
|
limit = 20
|
||||||
for tag in recipe.get("tags", []) or []:
|
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
|
||||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
|
||||||
|
|
||||||
sorted_tags = [
|
sorted_tags = [
|
||||||
{"tag": tag, "count": count} for tag, count in tag_counts.items()
|
{"tag": tag, "count": count} for tag, count in tag_counts.items()
|
||||||
@@ -333,6 +333,55 @@ class RecipeQueryHandler:
|
|||||||
self._logger.error("Error retrieving top tags: %s", exc, exc_info=True)
|
self._logger.error("Error retrieving top tags: %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 search_tags(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
query = request.query.get("q", "")
|
||||||
|
limit = int(request.query.get("limit", "20"))
|
||||||
|
if limit < 0:
|
||||||
|
limit = 20
|
||||||
|
elif limit > 200:
|
||||||
|
limit = 20
|
||||||
|
|
||||||
|
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
|
||||||
|
normalized_query = (query or "").strip().lower()
|
||||||
|
if not normalized_query:
|
||||||
|
sorted_tags = [
|
||||||
|
{"tag": tag, "count": count} for tag, count in tag_counts.items()
|
||||||
|
]
|
||||||
|
sorted_tags.sort(key=lambda entry: entry["count"], reverse=True)
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "tags": sorted_tags[: (limit if limit > 0 else 20)]}
|
||||||
|
)
|
||||||
|
|
||||||
|
matched = [
|
||||||
|
{"tag": tag, "count": count}
|
||||||
|
for tag, count in tag_counts.items()
|
||||||
|
if normalized_query in tag.lower()
|
||||||
|
]
|
||||||
|
matched.sort(key=lambda entry: entry["count"], reverse=True)
|
||||||
|
if limit == 0:
|
||||||
|
result = matched
|
||||||
|
else:
|
||||||
|
result = matched[:limit]
|
||||||
|
return web.json_response({"success": True, "tags": result})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error searching recipe tags: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def _get_recipe_tag_counts(self, recipe_scanner) -> Dict[str, int]:
|
||||||
|
"""Compute tag->count mapping from cached recipe data."""
|
||||||
|
cache = await recipe_scanner.get_cached_data()
|
||||||
|
tag_counts: Dict[str, int] = {}
|
||||||
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
|
for tag in recipe.get("tags", []) or []:
|
||||||
|
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||||
|
return tag_counts
|
||||||
|
|
||||||
async def get_base_models(self, request: web.Request) -> web.Response:
|
async def get_base_models(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
|
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
|
||||||
),
|
),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
|
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
|
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
|
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
|
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
|
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
|
||||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/search-tags", "search_tags"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
|
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
|
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
|
||||||
|
|||||||
+300
-32
@@ -38,6 +38,84 @@ def _clean_excludes() -> List[str]:
|
|||||||
return excludes
|
return excludes
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_preserved_items(plugin_root: str) -> tuple[str, list[str]]:
|
||||||
|
"""Move preserved user-data items to a temp directory outside *plugin_root*.
|
||||||
|
|
||||||
|
This ensures that ``git reset --hard``, ``git clean -fd``, and ZIP-based
|
||||||
|
replacement cannot touch these files even when ``-e`` exclusion patterns
|
||||||
|
are mishandled (e.g. on Windows where forward-slash patterns may not
|
||||||
|
match backslash-prefixed paths in some Git builds, or where file locks
|
||||||
|
prevent deletion/recreation).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``(backup_root, staged_names)``: the temp directory path and the
|
||||||
|
list of item names that were successfully moved.
|
||||||
|
"""
|
||||||
|
backup_root = tempfile.mkdtemp(prefix='lora_manager_update_')
|
||||||
|
staged: list[str] = []
|
||||||
|
for name in _PRESERVE_DIRS:
|
||||||
|
src = os.path.join(plugin_root, name)
|
||||||
|
if not os.path.lexists(src):
|
||||||
|
continue
|
||||||
|
dst = os.path.join(backup_root, name)
|
||||||
|
try:
|
||||||
|
shutil.move(src, dst)
|
||||||
|
staged.append(name)
|
||||||
|
logger.debug("Staged '%s' for update safety", name)
|
||||||
|
except OSError:
|
||||||
|
# ``shutil.move`` may fail on Windows if a file handle inside
|
||||||
|
# the directory is still open (e.g. a SQLite WAL file). Fall
|
||||||
|
# back to copy-then-remove.
|
||||||
|
logger.debug("Move failed for '%s', falling back to copy", name)
|
||||||
|
try:
|
||||||
|
if os.path.isdir(src) and not os.path.islink(src):
|
||||||
|
shutil.copytree(src, dst, symlinks=True)
|
||||||
|
shutil.rmtree(src, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
os.remove(src)
|
||||||
|
staged.append(name)
|
||||||
|
logger.info("Copied (then removed) '%s' for update safety", name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Could not stage '%s': %s (will rely on git -e / skip lists)", name, exc
|
||||||
|
)
|
||||||
|
return backup_root, staged
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_preserved_items(plugin_root: str, backup_root: str, staged: list[str]) -> None:
|
||||||
|
"""Move staged items back from *backup_root* into *plugin_root*.
|
||||||
|
|
||||||
|
Any leftover placeholder at the destination (created by git checkout or
|
||||||
|
ZIP extraction) is removed before the move.
|
||||||
|
"""
|
||||||
|
for name in staged:
|
||||||
|
src = os.path.join(backup_root, name)
|
||||||
|
dst = os.path.join(plugin_root, name)
|
||||||
|
try:
|
||||||
|
if os.path.lexists(dst):
|
||||||
|
if os.path.isdir(dst) and not os.path.islink(dst):
|
||||||
|
shutil.rmtree(dst, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
os.remove(dst)
|
||||||
|
shutil.move(src, dst)
|
||||||
|
logger.debug("Restored '%s' after update", name)
|
||||||
|
except OSError:
|
||||||
|
logger.debug("Move failed restoring '%s', falling back to copy", name)
|
||||||
|
try:
|
||||||
|
if os.path.isdir(src) and not os.path.islink(src):
|
||||||
|
shutil.copytree(src, dst, symlinks=True, dirs_exist_ok=True)
|
||||||
|
shutil.rmtree(src, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
os.remove(src)
|
||||||
|
logger.info("Copied '%s' back after update", name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to restore '%s': %s", name, exc)
|
||||||
|
shutil.rmtree(backup_root, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateRoutes:
|
class UpdateRoutes:
|
||||||
"""Routes for handling plugin update checks"""
|
"""Routes for handling plugin update checks"""
|
||||||
|
|
||||||
@@ -47,6 +125,7 @@ class UpdateRoutes:
|
|||||||
app.router.add_get('/api/lm/check-updates', UpdateRoutes.check_updates)
|
app.router.add_get('/api/lm/check-updates', UpdateRoutes.check_updates)
|
||||||
app.router.add_get('/api/lm/version-info', UpdateRoutes.get_version_info)
|
app.router.add_get('/api/lm/version-info', UpdateRoutes.get_version_info)
|
||||||
app.router.add_post('/api/lm/perform-update', UpdateRoutes.perform_update)
|
app.router.add_post('/api/lm/perform-update', UpdateRoutes.perform_update)
|
||||||
|
app.router.add_post('/api/lm/switch-channel', UpdateRoutes.switch_channel)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def check_updates(request):
|
async def check_updates(request):
|
||||||
@@ -65,10 +144,17 @@ class UpdateRoutes:
|
|||||||
|
|
||||||
# Fetch remote version from GitHub
|
# Fetch remote version from GitHub
|
||||||
if nightly:
|
if nightly:
|
||||||
remote_version, changelog = await UpdateRoutes._get_nightly_version()
|
local_hash = git_info.get('short_hash', '')
|
||||||
releases = None
|
nightly_version, releases_result = await asyncio.gather(
|
||||||
|
UpdateRoutes._get_nightly_version(local_hash),
|
||||||
|
UpdateRoutes._get_remote_version()
|
||||||
|
)
|
||||||
|
remote_version, _, behind_by, commit_date = nightly_version
|
||||||
|
_, changelog, releases = releases_result
|
||||||
else:
|
else:
|
||||||
remote_version, changelog, releases = await UpdateRoutes._get_remote_version()
|
remote_version, changelog, releases = await UpdateRoutes._get_remote_version()
|
||||||
|
behind_by = 0
|
||||||
|
commit_date = ''
|
||||||
|
|
||||||
# Compare versions
|
# Compare versions
|
||||||
if nightly:
|
if nightly:
|
||||||
@@ -81,6 +167,10 @@ class UpdateRoutes:
|
|||||||
remote_version.replace('v', '')
|
remote_version.replace('v', '')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||||
|
has_git = os.path.exists(os.path.join(plugin_root, '.git'))
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
'success': True,
|
'success': True,
|
||||||
'current_version': local_version,
|
'current_version': local_version,
|
||||||
@@ -88,13 +178,13 @@ class UpdateRoutes:
|
|||||||
'update_available': update_available,
|
'update_available': update_available,
|
||||||
'changelog': changelog,
|
'changelog': changelog,
|
||||||
'git_info': git_info,
|
'git_info': git_info,
|
||||||
'nightly': nightly
|
'nightly': nightly,
|
||||||
|
'has_git': has_git,
|
||||||
|
'releases': releases,
|
||||||
|
'behind_by': behind_by,
|
||||||
|
'commit_date': commit_date
|
||||||
}
|
}
|
||||||
|
|
||||||
# Include releases list for stable mode
|
|
||||||
if releases is not None:
|
|
||||||
response_data['releases'] = releases
|
|
||||||
|
|
||||||
return web.json_response(response_data)
|
return web.json_response(response_data)
|
||||||
|
|
||||||
except NETWORK_EXCEPTIONS as e:
|
except NETWORK_EXCEPTIONS as e:
|
||||||
@@ -126,9 +216,14 @@ class UpdateRoutes:
|
|||||||
# Format: version-short_hash
|
# Format: version-short_hash
|
||||||
version_string = f"{local_version}-{short_hash}"
|
version_string = f"{local_version}-{short_hash}"
|
||||||
|
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||||
|
has_git = os.path.exists(os.path.join(plugin_root, '.git'))
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'version': version_string
|
'version': version_string,
|
||||||
|
'has_git': has_git
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -156,20 +251,22 @@ class UpdateRoutes:
|
|||||||
if os.path.exists(settings_path):
|
if os.path.exists(settings_path):
|
||||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||||
settings_backup = f.read()
|
settings_backup = f.read()
|
||||||
logger.info("Backed up settings.json")
|
logger.debug("Backed up settings.json (%d bytes)", len(settings_backup))
|
||||||
|
|
||||||
|
staged_backup_dir, staged_items = _stage_preserved_items(plugin_root)
|
||||||
|
try:
|
||||||
git_folder = os.path.join(plugin_root, '.git')
|
git_folder = os.path.join(plugin_root, '.git')
|
||||||
if os.path.exists(git_folder):
|
if os.path.exists(git_folder):
|
||||||
# Git update
|
|
||||||
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
|
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
|
||||||
else:
|
else:
|
||||||
# Fallback: Download ZIP and replace files
|
|
||||||
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
||||||
|
finally:
|
||||||
|
_restore_preserved_items(plugin_root, staged_backup_dir, staged_items)
|
||||||
|
|
||||||
if settings_backup and success:
|
if settings_backup and success:
|
||||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(settings_backup)
|
f.write(settings_backup)
|
||||||
logger.info("Restored settings.json")
|
logger.debug("Restored settings.json content (%d bytes)", len(settings_backup))
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
@@ -190,6 +287,164 @@ class UpdateRoutes:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def switch_channel(request):
|
||||||
|
"""
|
||||||
|
Switch between release and nightly update channels.
|
||||||
|
|
||||||
|
ZIP/CNR install → Nightly: git init + checkout main (one-way upgrade)
|
||||||
|
Git install → Release: git checkout latest tag (.git preserved)
|
||||||
|
ZIP/CNR install → Release: ZIP download (no .git, stays in ZIP mode)
|
||||||
|
Git install → Nightly: git checkout main + pull
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
body = await request.json() if request.has_body else {}
|
||||||
|
channel = body.get('channel', '')
|
||||||
|
|
||||||
|
if channel not in ('release', 'nightly'):
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Invalid channel: {channel}. Must be "release" or "nightly".'
|
||||||
|
})
|
||||||
|
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||||
|
|
||||||
|
settings_path = ensure_settings_file(logger)
|
||||||
|
settings_backup = None
|
||||||
|
if os.path.exists(settings_path):
|
||||||
|
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||||
|
settings_backup = f.read()
|
||||||
|
logger.debug("Backed up settings.json before channel switch (%d bytes)", len(settings_backup))
|
||||||
|
|
||||||
|
staged_backup_dir, staged_items = _stage_preserved_items(plugin_root)
|
||||||
|
try:
|
||||||
|
git_folder = os.path.join(plugin_root, '.git')
|
||||||
|
|
||||||
|
if channel == 'nightly':
|
||||||
|
git_backup = None
|
||||||
|
if os.path.exists(git_folder):
|
||||||
|
git_backup = UpdateRoutes._backup_git(git_folder, 'nightly')
|
||||||
|
|
||||||
|
success = False
|
||||||
|
new_version = ''
|
||||||
|
try:
|
||||||
|
if os.path.exists(git_folder):
|
||||||
|
success, new_version = await UpdateRoutes._perform_git_update(
|
||||||
|
plugin_root, nightly=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
success, new_version = UpdateRoutes._init_git_repo(plugin_root)
|
||||||
|
finally:
|
||||||
|
UpdateRoutes._restore_git(git_backup, git_folder, success, 'nightly')
|
||||||
|
else:
|
||||||
|
success = False
|
||||||
|
new_version = ''
|
||||||
|
if os.path.exists(git_folder):
|
||||||
|
success, new_version = await UpdateRoutes._perform_git_update(
|
||||||
|
plugin_root, nightly=False
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
tracking_file = os.path.join(plugin_root, '.tracking')
|
||||||
|
if os.path.exists(tracking_file):
|
||||||
|
os.remove(tracking_file)
|
||||||
|
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
||||||
|
finally:
|
||||||
|
_restore_preserved_items(plugin_root, staged_backup_dir, staged_items)
|
||||||
|
|
||||||
|
if settings_backup and success:
|
||||||
|
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(settings_backup)
|
||||||
|
logger.debug("Restored settings.json content after channel switch (%d bytes)", len(settings_backup))
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'channel': channel,
|
||||||
|
'new_version': new_version,
|
||||||
|
'message': f'Switched to {channel} channel'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Failed to switch to {channel} channel'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to switch channel: %s", e, exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _init_git_repo(plugin_root: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Initialize a Git repository in a ZIP-installed plugin folder.
|
||||||
|
Clones the remote history and checks out main branch.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import git
|
||||||
|
except ImportError:
|
||||||
|
logger.error(
|
||||||
|
"GitPython is not available: cannot initialize git repo. "
|
||||||
|
"Install git or set $GIT_PYTHON_GIT_EXECUTABLE to the git binary path."
|
||||||
|
)
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
clean_excludes = _clean_excludes()
|
||||||
|
|
||||||
|
try:
|
||||||
|
repo = git.Repo.init(plugin_root)
|
||||||
|
origin = repo.create_remote(
|
||||||
|
'origin',
|
||||||
|
'https://github.com/willmiao/ComfyUI-Lora-Manager.git'
|
||||||
|
)
|
||||||
|
origin.fetch()
|
||||||
|
|
||||||
|
repo.create_head('main', origin.refs.main)
|
||||||
|
repo.git.checkout('main', '--force')
|
||||||
|
repo.git.reset('--hard')
|
||||||
|
repo.git.clean('-fd', *clean_excludes)
|
||||||
|
|
||||||
|
tracking_file = os.path.join(plugin_root, '.tracking')
|
||||||
|
if os.path.exists(tracking_file):
|
||||||
|
os.remove(tracking_file)
|
||||||
|
logger.info("Removed .tracking file (now in git mode)")
|
||||||
|
|
||||||
|
new_version = f"main-{repo.head.commit.hexsha[:7]}"
|
||||||
|
logger.info("Initialized git repo on main branch: %s", new_version)
|
||||||
|
return True, new_version
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to initialize git repo: %s", e, exc_info=True)
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _backup_git(git_folder, label):
|
||||||
|
try:
|
||||||
|
backup_dir = tempfile.mkdtemp()
|
||||||
|
backup = os.path.join(backup_dir, '.git')
|
||||||
|
shutil.copytree(git_folder, backup)
|
||||||
|
logger.info("Backed up .git before switching to %s", label)
|
||||||
|
return backup
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to backup .git before %s switch: %s", label, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _restore_git(git_backup, git_folder, success, label):
|
||||||
|
if git_backup and not success:
|
||||||
|
try:
|
||||||
|
if os.path.exists(git_folder):
|
||||||
|
shutil.rmtree(git_folder)
|
||||||
|
shutil.copytree(git_backup, git_folder)
|
||||||
|
logger.info("Restored .git after failed %s switch", label)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to restore .git after %s switch: %s", label, e)
|
||||||
|
if git_backup:
|
||||||
|
shutil.rmtree(os.path.dirname(git_backup), ignore_errors=True)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
|
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
@@ -244,8 +499,7 @@ class UpdateRoutes:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Could not close downloaded-version history database", exc_info=True)
|
logger.debug("Could not close downloaded-version history database", exc_info=True)
|
||||||
|
|
||||||
# Skip settings.json, civitai, model cache and runtime cache folders
|
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=list(_PRESERVE_DIRS))
|
||||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups', 'stats'])
|
|
||||||
|
|
||||||
# Extract ZIP to temp dir
|
# Extract ZIP to temp dir
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
@@ -255,7 +509,7 @@ class UpdateRoutes:
|
|||||||
extracted_root = next(os.scandir(tmp_dir)).path
|
extracted_root = next(os.scandir(tmp_dir)).path
|
||||||
|
|
||||||
# Copy files, skipping user data that should be preserved
|
# Copy files, skipping user data that should be preserved
|
||||||
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups', 'stats'}
|
skip_items = set(_PRESERVE_DIRS)
|
||||||
for item in os.listdir(extracted_root):
|
for item in os.listdir(extracted_root):
|
||||||
if item in skip_items:
|
if item in skip_items:
|
||||||
continue
|
continue
|
||||||
@@ -272,7 +526,7 @@ class UpdateRoutes:
|
|||||||
# for ComfyUI Manager to work properly
|
# for ComfyUI Manager to work properly
|
||||||
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
||||||
tracking_files = []
|
tracking_files = []
|
||||||
skip_tracked = {'civitai', 'wildcards', 'backups', 'stats'}
|
skip_tracked = set(_PRESERVE_DIRS) - {'settings.json'}
|
||||||
for root, dirs, files in os.walk(extracted_root):
|
for root, dirs, files in os.walk(extracted_root):
|
||||||
# Skip user data directories and their contents
|
# Skip user data directories and their contents
|
||||||
rel_root = os.path.relpath(root, extracted_root)
|
rel_root = os.path.relpath(root, extracted_root)
|
||||||
@@ -296,6 +550,7 @@ class UpdateRoutes:
|
|||||||
logger.error(f"ZIP update failed: {e}", exc_info=True)
|
logger.error(f"ZIP update failed: {e}", exc_info=True)
|
||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def _clean_plugin_folder(plugin_root, skip_files=None):
|
def _clean_plugin_folder(plugin_root, skip_files=None):
|
||||||
skip_files = skip_files or []
|
skip_files = skip_files or []
|
||||||
for item in os.listdir(plugin_root):
|
for item in os.listdir(plugin_root):
|
||||||
@@ -308,41 +563,54 @@ class UpdateRoutes:
|
|||||||
os.remove(path)
|
os.remove(path)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get_nightly_version() -> tuple[str, List[str]]:
|
async def _get_nightly_version(local_hash: str = "") -> tuple[str, List[str], int, str]:
|
||||||
"""
|
|
||||||
Fetch latest commit from main branch
|
|
||||||
"""
|
|
||||||
repo_owner = "willmiao"
|
repo_owner = "willmiao"
|
||||||
repo_name = "ComfyUI-Lora-Manager"
|
repo_name = "ComfyUI-Lora-Manager"
|
||||||
|
|
||||||
# Use GitHub API to fetch the latest commit from main branch
|
|
||||||
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/commits/main"
|
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/commits/main"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
downloader = await get_downloader()
|
downloader = await get_downloader()
|
||||||
success, data = await downloader.make_request('GET', github_url, custom_headers={'Accept': 'application/vnd.github+json'})
|
success, data = await downloader.make_request(
|
||||||
|
'GET', github_url,
|
||||||
|
custom_headers={'Accept': 'application/vnd.github+json'}
|
||||||
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
logger.warning(f"Failed to fetch GitHub commit: {data}")
|
logger.warning("Failed to fetch GitHub commit: %s", data)
|
||||||
return "main", []
|
return "main", [], 0, ""
|
||||||
|
|
||||||
commit_sha = data.get('sha', '')[:7] # Short hash
|
commit_sha = data.get('sha', '')[:7]
|
||||||
commit_message = data.get('commit', {}).get('message', '')
|
commit_message = data.get('commit', {}).get('message', '')
|
||||||
|
commit_date = data.get('commit', {}).get('committer', {}).get('date', '')[:10]
|
||||||
|
|
||||||
# Format as "main-{short_hash}"
|
|
||||||
version = f"main-{commit_sha}"
|
version = f"main-{commit_sha}"
|
||||||
|
|
||||||
# Use commit message as changelog
|
|
||||||
changelog = [commit_message] if commit_message else []
|
changelog = [commit_message] if commit_message else []
|
||||||
|
|
||||||
return version, changelog
|
behind_by = 0
|
||||||
|
if local_hash and local_hash not in ('unknown', 'stable'):
|
||||||
|
compare_url = (
|
||||||
|
f"https://api.github.com/repos/{repo_owner}/{repo_name}"
|
||||||
|
f"/compare/{local_hash}...main"
|
||||||
|
)
|
||||||
|
c_ok, c_data = await downloader.make_request(
|
||||||
|
'GET', compare_url,
|
||||||
|
custom_headers={'Accept': 'application/vnd.github+json'}
|
||||||
|
)
|
||||||
|
if c_ok:
|
||||||
|
if c_data.get('status') in ('ahead', 'diverged'):
|
||||||
|
behind_by = c_data.get('ahead_by', 0)
|
||||||
|
else:
|
||||||
|
behind_by = c_data.get('behind_by', 0)
|
||||||
|
|
||||||
|
return version, changelog, behind_by, commit_date
|
||||||
|
|
||||||
except NETWORK_EXCEPTIONS as e:
|
except NETWORK_EXCEPTIONS as e:
|
||||||
logger.warning("Unable to reach GitHub for nightly version: %s", e)
|
logger.warning("Unable to reach GitHub for nightly version: %s", e)
|
||||||
return "main", []
|
return "main", [], 0, ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching nightly version: {e}", exc_info=True)
|
logger.error("Error fetching nightly version: %s", e, exc_info=True)
|
||||||
return "main", []
|
return "main", [], 0, ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _compare_nightly_versions(local_git_info: Dict[str, str], remote_version: str) -> bool:
|
def _compare_nightly_versions(local_git_info: Dict[str, str], remote_version: str) -> bool:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
import random
|
||||||
|
from typing import Any, Dict, List, Optional, Type, Union, TYPE_CHECKING
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -109,12 +110,15 @@ class BaseModelService(ABC):
|
|||||||
if civitai_model_id is not None:
|
if civitai_model_id is not None:
|
||||||
sorted_data = [
|
sorted_data = [
|
||||||
item for item in sorted_data
|
item for item in sorted_data
|
||||||
if self._extract_model_id(item) == civitai_model_id
|
if self._extract_group_key(item) == civitai_model_id
|
||||||
]
|
]
|
||||||
# VLM mode: always sort by version ID descending (newest version first),
|
# VLM mode: always sort by version ID descending (newest version first),
|
||||||
# regardless of the current sort_by preference.
|
# regardless of the current sort_by preference.
|
||||||
|
# Fall back to modified timestamp for non-CivitAI sources.
|
||||||
sorted_data.sort(
|
sorted_data.sort(
|
||||||
key=lambda x: self._extract_version_id(x) or 0,
|
key=lambda x: self._extract_version_id(x)
|
||||||
|
or x.get("modified", 0)
|
||||||
|
or 0,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,18 +133,21 @@ 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"
|
||||||
|
|
||||||
dedup_map = {} # (modelId [,base_model]) -> (item, version_id)
|
dedup_map = {} # (modelId [,base_model]) -> (item, version_or_modified)
|
||||||
version_counter = {} # same-key -> count
|
version_counter = {} # same-key -> count
|
||||||
standalone = []
|
standalone = []
|
||||||
for item in sorted_data:
|
for item in sorted_data:
|
||||||
mid = self._extract_model_id(item)
|
mid = self._extract_group_key(item)
|
||||||
if mid is None:
|
if mid is None:
|
||||||
standalone.append(item)
|
standalone.append(item)
|
||||||
continue
|
continue
|
||||||
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||||
# Count all versions per key
|
# Count all versions per key
|
||||||
version_counter[key] = version_counter.get(key, 0) + 1
|
version_counter[key] = version_counter.get(key, 0) + 1
|
||||||
vid = self._extract_version_id(item) or 0
|
# Prefer CivitAI version_id; fall back to modified timestamp
|
||||||
|
vid = self._extract_version_id(item)
|
||||||
|
if vid is None:
|
||||||
|
vid = item.get("modified", 0) or 0
|
||||||
if key not in dedup_map or vid > dedup_map[key][1]:
|
if key not in dedup_map or vid > dedup_map[key][1]:
|
||||||
dedup_map[key] = (item, vid)
|
dedup_map[key] = (item, vid)
|
||||||
# Attach version_count to each surviving grouped item (shallow copy
|
# Attach version_count to each surviving grouped item (shallow copy
|
||||||
@@ -174,16 +181,19 @@ class BaseModelService(ABC):
|
|||||||
model_groups: Dict[Any, List[Dict]] = {}
|
model_groups: Dict[Any, List[Dict]] = {}
|
||||||
ungrouped_standalone: List[Dict] = []
|
ungrouped_standalone: List[Dict] = []
|
||||||
for item in sorted_data:
|
for item in sorted_data:
|
||||||
mid = self._extract_model_id(item)
|
mid = self._extract_group_key(item)
|
||||||
if mid is None:
|
if mid is None:
|
||||||
ungrouped_standalone.append(item)
|
ungrouped_standalone.append(item)
|
||||||
continue
|
continue
|
||||||
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||||
model_groups.setdefault(key, []).append(item)
|
model_groups.setdefault(key, []).append(item)
|
||||||
# Sort versions within each group by version id descending
|
# Sort versions within each group by version id (descending);
|
||||||
|
# fall back to modified timestamp for non-CivitAI sources.
|
||||||
for items in model_groups.values():
|
for items in model_groups.values():
|
||||||
items.sort(
|
items.sort(
|
||||||
key=lambda x: self._extract_version_id(x) or 0,
|
key=lambda x: self._extract_version_id(x)
|
||||||
|
or x.get("modified", 0)
|
||||||
|
or 0,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
# Sort groups by version count
|
# Sort groups by version count
|
||||||
@@ -381,6 +391,12 @@ class BaseModelService(ABC):
|
|||||||
(item.get("model_name") or item.get("file_name") or "").lower(),
|
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||||
item.get("file_path", "").lower(),
|
item.get("file_path", "").lower(),
|
||||||
)
|
)
|
||||||
|
elif key_name == "random":
|
||||||
|
# Seeded random shuffle: same seed -> same order (stable pagination)
|
||||||
|
rng = random.Random(sort_params.seed or "random")
|
||||||
|
result = list(data)
|
||||||
|
rng.shuffle(result)
|
||||||
|
return result
|
||||||
elif key_name == "size":
|
elif key_name == "size":
|
||||||
key_fn = lambda item: (
|
key_fn = lambda item: (
|
||||||
int(item.get("size", 0) or 0),
|
int(item.get("size", 0) or 0),
|
||||||
@@ -697,6 +713,33 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
return annotated
|
return annotated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_hf_group_key(item: Dict) -> Optional[str]:
|
||||||
|
"""Extract `hf:{owner}/{repo}` from item's ``hf_url``, or None."""
|
||||||
|
hf_url = item.get("hf_url") if isinstance(item, dict) else None
|
||||||
|
if not hf_url or not isinstance(hf_url, str):
|
||||||
|
return None
|
||||||
|
m = re.match(
|
||||||
|
r"https?://huggingface\.co/([^/]+/[^/]+)", hf_url.strip()
|
||||||
|
)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return f"hf:{m.group(1)}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_group_key(item: Dict) -> Union[int, str, None]:
|
||||||
|
"""Return the group identity key: CivitAI modelId (int) or HF repo (str).
|
||||||
|
|
||||||
|
Preference order:
|
||||||
|
1. CivitAI ``modelId`` (int)
|
||||||
|
2. HF repo identity ``hf:{owner}/{repo}`` (str)
|
||||||
|
3. ``None`` (no known grouping source)
|
||||||
|
"""
|
||||||
|
mid = BaseModelService._extract_model_id(item)
|
||||||
|
if mid is not None:
|
||||||
|
return mid
|
||||||
|
return BaseModelService._extract_hf_group_key(item)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_model_id(item: Dict) -> Optional[int]:
|
def _extract_model_id(item: Dict) -> Optional[int]:
|
||||||
civitai = item.get("civitai") if isinstance(item, dict) else None
|
civitai = item.get("civitai") if isinstance(item, dict) else None
|
||||||
@@ -804,6 +847,12 @@ class BaseModelService(ABC):
|
|||||||
"""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(
|
||||||
|
self, query: str, limit: int = 50
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""Search tags by substring, sorted by frequency"""
|
||||||
|
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]:
|
||||||
"""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)
|
||||||
@@ -1092,6 +1141,11 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
Listing/search endpoints return lightweight cache entries; this method performs
|
Listing/search endpoints return lightweight cache entries; this method performs
|
||||||
a lazy read of the on-disk metadata snapshot when callers need full detail.
|
a lazy read of the on-disk metadata snapshot when callers need full detail.
|
||||||
|
|
||||||
|
As a beneficial side effect, the in-memory and persistent caches are
|
||||||
|
opportunistically synchronised with the on-disk metadata — this keeps the
|
||||||
|
caches fresh even when a ``.metadata.json`` file was edited outside of the
|
||||||
|
normal save path (e.g. manually or by an external script).
|
||||||
"""
|
"""
|
||||||
metadata, should_skip = await MetadataManager.load_metadata(
|
metadata, should_skip = await MetadataManager.load_metadata(
|
||||||
file_path, self.metadata_class
|
file_path, self.metadata_class
|
||||||
@@ -1109,6 +1163,19 @@ class BaseModelService(ABC):
|
|||||||
MetadataManager.save_metadata(file_path, metadata)
|
MetadataManager.save_metadata(file_path, metadata)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Opportunistically sync the in-memory + persistent caches.
|
||||||
|
# The .metadata.json disk read is already paid for; the sync only
|
||||||
|
# performs work when the cache is actually stale, and uses targeted,
|
||||||
|
# in-place operations to minimise overhead even with large model sets.
|
||||||
|
#
|
||||||
|
# Fire-and-forget by design: the task is intentionally untracked.
|
||||||
|
# sync_cache_from_metadata handles its own errors internally.
|
||||||
|
asyncio.create_task(
|
||||||
|
self.scanner.sync_cache_from_metadata(
|
||||||
|
file_path, metadata.to_dict()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
||||||
|
|
||||||
async def get_model_description(self, file_path: str) -> Optional[str]:
|
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -114,6 +114,13 @@ class CheckpointScanner(ModelScanner):
|
|||||||
and metadata.hash_status == "completed"
|
and metadata.hash_status == "completed"
|
||||||
and metadata.sha256
|
and metadata.sha256
|
||||||
):
|
):
|
||||||
|
# Ensure the in-memory hash index is populated even when
|
||||||
|
# the hash was already computed and persisted to the metadata
|
||||||
|
# file. Without this, usage tracking (and any other caller
|
||||||
|
# that queries get_hash_by_filename first) will miss on every
|
||||||
|
# lookup and keep calling back into this method, creating a
|
||||||
|
# tight loop that never populates the index.
|
||||||
|
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
|
||||||
return metadata.sha256
|
return metadata.sha256
|
||||||
|
|
||||||
async with self._hash_calculation_lock:
|
async with self._hash_calculation_lock:
|
||||||
@@ -125,6 +132,7 @@ 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)
|
||||||
return metadata.sha256
|
return metadata.sha256
|
||||||
|
|
||||||
task = self._hash_calculation_tasks.get(real_path)
|
task = self._hash_calculation_tasks.get(real_path)
|
||||||
@@ -175,6 +183,9 @@ class CheckpointScanner(ModelScanner):
|
|||||||
|
|
||||||
# Check if hash is already calculated
|
# Check if hash is already calculated
|
||||||
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
|
||||||
|
# hashes, mirroring the fix in calculate_hash_for_model.
|
||||||
|
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
|
||||||
return metadata.sha256
|
return metadata.sha256
|
||||||
|
|
||||||
# Update status to calculating
|
# Update status to calculating
|
||||||
@@ -193,6 +204,20 @@ class CheckpointScanner(ModelScanner):
|
|||||||
# Update hash index
|
# Update hash index
|
||||||
self._hash_index.add_entry(sha256.lower(), file_path)
|
self._hash_index.add_entry(sha256.lower(), file_path)
|
||||||
|
|
||||||
|
# Update the in-memory cache entry so that subsequent
|
||||||
|
# _persist_current_cache / _save_persistent_cache calls
|
||||||
|
# write the hash back to the SQLite models table. Without
|
||||||
|
# this the hash only lives in the metadata file and the
|
||||||
|
# in-memory hash index, both of which are lost across
|
||||||
|
# restarts, causing the same re-computation loop on the
|
||||||
|
# next session.
|
||||||
|
if self._cache is not None and self._cache.raw_data:
|
||||||
|
for entry in self._cache.raw_data:
|
||||||
|
if entry.get("file_path") == file_path:
|
||||||
|
entry["sha256"] = sha256.lower()
|
||||||
|
entry["hash_status"] = "completed"
|
||||||
|
break
|
||||||
|
|
||||||
logger.info(f"Hash calculated for checkpoint: {file_path}")
|
logger.info(f"Hash calculated for checkpoint: {file_path}")
|
||||||
return sha256
|
return sha256
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
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
|
||||||
from .connectivity_guard import (
|
from .connectivity_guard import (
|
||||||
@@ -19,6 +20,12 @@ from ..utils.civitai_utils import resolve_license_payload
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Best-effort cache for creator model counts, keyed by lowercase username.
|
||||||
|
# Values are (monotonic timestamp, count or None); None results are cached
|
||||||
|
# too so repeated failures don't hammer the API.
|
||||||
|
_CREATOR_COUNT_CACHE_TTL_SECONDS = 600
|
||||||
|
_creator_model_count_cache: Dict[str, Tuple[float, Optional[int]]] = {}
|
||||||
|
|
||||||
|
|
||||||
class CivitaiClient:
|
class CivitaiClient:
|
||||||
_instance = None
|
_instance = None
|
||||||
@@ -743,17 +750,34 @@ class CivitaiClient:
|
|||||||
|
|
||||||
return all_versions if all_versions else None
|
return all_versions if all_versions else None
|
||||||
|
|
||||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
async def get_user_models(
|
||||||
"""Fetch all models for a specific Civitai user."""
|
self, username: str, cursor: Optional[str] = None
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Fetch one page (up to 100 models) for a specific Civitai user.
|
||||||
|
|
||||||
|
Returns ``{"items": [...], "nextCursor": <str|None>}`` on success,
|
||||||
|
or None on failure. Pass ``cursor`` (from a previous response's
|
||||||
|
``nextCursor``) to fetch subsequent pages.
|
||||||
|
"""
|
||||||
if not username:
|
if not username:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
params: Dict[str, Any] = {
|
||||||
|
"username": username,
|
||||||
|
"nsfw": "true",
|
||||||
|
"limit": 100,
|
||||||
|
"sort": "Newest",
|
||||||
|
"period": "AllTime",
|
||||||
|
}
|
||||||
|
if cursor:
|
||||||
|
params["cursor"] = cursor
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success, result = await self._make_request(
|
success, result = await self._make_request(
|
||||||
"GET",
|
"GET",
|
||||||
f"{self.base_url}/models",
|
f"{self.base_url}/models",
|
||||||
use_auth=True,
|
use_auth=True,
|
||||||
params={"username": username, "nsfw": "true"},
|
params=params,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
@@ -765,7 +789,7 @@ class CivitaiClient:
|
|||||||
|
|
||||||
items = result.get("items") if isinstance(result, dict) else None
|
items = result.get("items") if isinstance(result, dict) else None
|
||||||
if not isinstance(items, list):
|
if not isinstance(items, list):
|
||||||
return []
|
items = []
|
||||||
|
|
||||||
for model in items:
|
for model in items:
|
||||||
versions = model.get("modelVersions")
|
versions = model.get("modelVersions")
|
||||||
@@ -774,9 +798,68 @@ class CivitaiClient:
|
|||||||
for version in versions:
|
for version in versions:
|
||||||
self._remove_comfy_metadata(version)
|
self._remove_comfy_metadata(version)
|
||||||
|
|
||||||
return items
|
next_cursor: Optional[str] = None
|
||||||
|
metadata = result.get("metadata") if isinstance(result, dict) else None
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
raw_cursor = metadata.get("nextCursor")
|
||||||
|
if raw_cursor is not None:
|
||||||
|
next_cursor = str(raw_cursor)
|
||||||
|
|
||||||
|
return {"items": items, "nextCursor": next_cursor}
|
||||||
except RateLimitError:
|
except RateLimitError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Error fetching models for %s: %s", username, exc)
|
logger.error("Error fetching models for %s: %s", username, exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def get_creator_model_count(self, username: str) -> Optional[int]:
|
||||||
|
"""Best-effort lookup of a creator's published model count.
|
||||||
|
|
||||||
|
Uses the ``/creators`` endpoint (a contains-match query), picking the
|
||||||
|
entry whose username matches exactly (case-insensitive). Returns None
|
||||||
|
on any failure; never raises. Results (including None) are cached
|
||||||
|
for ``_CREATOR_COUNT_CACHE_TTL_SECONDS``.
|
||||||
|
"""
|
||||||
|
if not username:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_key = username.lower()
|
||||||
|
cached = _creator_model_count_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
cached_at, cached_count = cached
|
||||||
|
if time.monotonic() - cached_at < _CREATOR_COUNT_CACHE_TTL_SECONDS:
|
||||||
|
return cached_count
|
||||||
|
|
||||||
|
count: Optional[int] = None
|
||||||
|
try:
|
||||||
|
success, result = await self._make_request(
|
||||||
|
"GET",
|
||||||
|
f"{self.base_url}/creators",
|
||||||
|
use_auth=True,
|
||||||
|
params={"query": username, "limit": 10},
|
||||||
|
)
|
||||||
|
|
||||||
|
if success and isinstance(result, dict):
|
||||||
|
creators = result.get("items")
|
||||||
|
if isinstance(creators, list):
|
||||||
|
for creator in creators:
|
||||||
|
if not isinstance(creator, dict):
|
||||||
|
continue
|
||||||
|
creator_name = creator.get("username")
|
||||||
|
if not isinstance(creator_name, str):
|
||||||
|
continue
|
||||||
|
if creator_name.lower() != cache_key:
|
||||||
|
continue
|
||||||
|
model_count = creator.get("modelCount")
|
||||||
|
if isinstance(model_count, (int, float)) and not isinstance(
|
||||||
|
model_count, bool
|
||||||
|
):
|
||||||
|
count = int(model_count)
|
||||||
|
break
|
||||||
|
except Exception as exc: # best-effort only, never propagate
|
||||||
|
logger.debug(
|
||||||
|
"Failed to fetch creator model count for %s: %s", username, exc
|
||||||
|
)
|
||||||
|
|
||||||
|
_creator_model_count_cache[cache_key] = (time.monotonic(), count)
|
||||||
|
return count
|
||||||
|
|||||||
@@ -682,7 +682,10 @@ class DownloadManager:
|
|||||||
u for u in download_urls if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
u for u in download_urls if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||||
]
|
]
|
||||||
download_urls = non_civitai_urls + civitai_urls
|
download_urls = non_civitai_urls + civitai_urls
|
||||||
else:
|
|
||||||
|
# Fallback: when mirrors is empty or all mirrors have been deleted,
|
||||||
|
# use the file's downloadUrl directly (e.g. CivitAI download endpoint).
|
||||||
|
if not download_urls:
|
||||||
download_url = file_info.get("downloadUrl")
|
download_url = file_info.get("downloadUrl")
|
||||||
if download_url:
|
if download_url:
|
||||||
download_urls.append(normalize_civitai_download_url(download_url))
|
download_urls.append(normalize_civitai_download_url(download_url))
|
||||||
@@ -1386,7 +1389,17 @@ class DownloadManager:
|
|||||||
|
|
||||||
# Update save directory with relative path if provided
|
# Update save directory with relative path if provided
|
||||||
if relative_path:
|
if relative_path:
|
||||||
|
base_save_dir = save_dir
|
||||||
save_dir = os.path.join(save_dir, relative_path)
|
save_dir = os.path.join(save_dir, relative_path)
|
||||||
|
# Security: validate path containment after joining
|
||||||
|
resolved_dir = os.path.abspath(os.path.normpath(save_dir))
|
||||||
|
base_dir = os.path.abspath(os.path.normpath(base_save_dir))
|
||||||
|
if not resolved_dir.startswith(base_dir + os.sep) and resolved_dir != base_dir:
|
||||||
|
logger.warning(
|
||||||
|
"Path traversal detected: %s escapes %s",
|
||||||
|
resolved_dir, base_dir,
|
||||||
|
)
|
||||||
|
return {"success": False, "error": "Download path is outside allowed directory"}
|
||||||
# Create directory if it doesn't exist
|
# Create directory if it doesn't exist
|
||||||
os.makedirs(save_dir, exist_ok=True)
|
os.makedirs(save_dir, exist_ok=True)
|
||||||
|
|
||||||
@@ -1520,35 +1533,8 @@ class DownloadManager:
|
|||||||
|
|
||||||
if not file_info:
|
if not file_info:
|
||||||
return {"success": False, "error": "No suitable file found in metadata"}
|
return {"success": False, "error": "No suitable file found in metadata"}
|
||||||
mirrors = file_info.get("mirrors") or []
|
|
||||||
download_urls = []
|
|
||||||
if mirrors:
|
|
||||||
for mirror in mirrors:
|
|
||||||
if mirror.get("deletedAt") is None and mirror.get("url"):
|
|
||||||
download_urls.append(
|
|
||||||
normalize_civitai_download_url(mirror["url"])
|
|
||||||
)
|
|
||||||
|
|
||||||
# When source is 'civarchive', prioritize non-Civitai URLs
|
download_urls = self._build_download_urls_from_file_info(file_info, source=source)
|
||||||
# This avoids failed downloads from deleted Civitai models
|
|
||||||
if source == "civarchive" and len(download_urls) > 1:
|
|
||||||
civitai_urls = [
|
|
||||||
u
|
|
||||||
for u in download_urls
|
|
||||||
if u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
|
||||||
]
|
|
||||||
non_civitai_urls = [
|
|
||||||
u
|
|
||||||
for u in download_urls
|
|
||||||
if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
|
||||||
]
|
|
||||||
download_urls = non_civitai_urls + civitai_urls
|
|
||||||
else:
|
|
||||||
download_url = file_info.get("downloadUrl")
|
|
||||||
if download_url:
|
|
||||||
download_urls.append(
|
|
||||||
normalize_civitai_download_url(download_url)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not download_urls:
|
if not download_urls:
|
||||||
return {"success": False, "error": "No mirror URL found"}
|
return {"success": False, "error": "No mirror URL found"}
|
||||||
@@ -1851,6 +1837,9 @@ class DownloadManager:
|
|||||||
model_tags, model_type
|
model_tags, model_type
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not first_tag:
|
||||||
|
first_tag = "no tags" # Default if no tags available
|
||||||
|
|
||||||
# Format the template with available data
|
# Format the template with available data
|
||||||
formatted_path = path_template
|
formatted_path = path_template
|
||||||
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
|
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
|
||||||
@@ -1866,6 +1855,15 @@ class DownloadManager:
|
|||||||
if model_type == "embedding":
|
if model_type == "embedding":
|
||||||
formatted_path = formatted_path.replace(" ", "_")
|
formatted_path = formatted_path.replace(" ", "_")
|
||||||
|
|
||||||
|
# Sanitize the resolved path to prevent path traversal:
|
||||||
|
# - Strip leading slashes (prevents os.path.join from treating path as absolute)
|
||||||
|
# - Collapse double slashes from empty placeholder substitutions
|
||||||
|
# - Strip trailing slashes for cleanliness
|
||||||
|
formatted_path = formatted_path.lstrip("/")
|
||||||
|
while "//" in formatted_path:
|
||||||
|
formatted_path = formatted_path.replace("//", "/")
|
||||||
|
formatted_path = formatted_path.rstrip("/")
|
||||||
|
|
||||||
return formatted_path
|
return formatted_path
|
||||||
|
|
||||||
async def _execute_download(
|
async def _execute_download(
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class DownloadQueueService:
|
|||||||
_instance: Optional[DownloadQueueService] = None
|
_instance: Optional[DownloadQueueService] = None
|
||||||
_class_lock: asyncio.Lock = asyncio.Lock()
|
_class_lock: asyncio.Lock = asyncio.Lock()
|
||||||
|
|
||||||
_SCHEMA = """
|
_SCHEMA_TABLES = """
|
||||||
CREATE TABLE IF NOT EXISTS download_queue (
|
CREATE TABLE IF NOT EXISTS download_queue (
|
||||||
download_id TEXT PRIMARY KEY,
|
download_id TEXT PRIMARY KEY,
|
||||||
model_id INTEGER,
|
model_id INTEGER,
|
||||||
@@ -76,6 +76,11 @@ class DownloadQueueService:
|
|||||||
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
|
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_CREATE_UNIQUE_INDEX = """
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_dh_download_id
|
||||||
|
ON download_history(download_id) WHERE download_id IS NOT NULL;
|
||||||
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_instance(cls) -> DownloadQueueService:
|
async def get_instance(cls) -> DownloadQueueService:
|
||||||
"""Return the singleton instance, creating it if necessary."""
|
"""Return the singleton instance, creating it if necessary."""
|
||||||
@@ -113,10 +118,39 @@ class DownloadQueueService:
|
|||||||
if self._schema_initialized:
|
if self._schema_initialized:
|
||||||
return
|
return
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
conn.executescript(self._SCHEMA)
|
conn.executescript(self._SCHEMA_TABLES)
|
||||||
|
|
||||||
|
# Creating the unique index on download_history.download_id can
|
||||||
|
# fail if pre-existing rows have duplicate values (e.g. from a
|
||||||
|
# previous version that lacked the index). Deduplicate first so
|
||||||
|
# that the migration does not crash on startup.
|
||||||
|
if not self._index_exists(conn, "idx_dh_download_id"):
|
||||||
|
self._remove_duplicate_download_ids(conn)
|
||||||
|
conn.executescript(self._CREATE_UNIQUE_INDEX)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
self._schema_initialized = True
|
self._schema_initialized = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _index_exists(conn: sqlite3.Connection, name: str) -> bool:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='index' AND name=?",
|
||||||
|
(name,),
|
||||||
|
).fetchone() is not None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _remove_duplicate_download_ids(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute("""
|
||||||
|
DELETE FROM download_history
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT MIN(id)
|
||||||
|
FROM download_history
|
||||||
|
WHERE download_id IS NOT NULL
|
||||||
|
GROUP BY download_id
|
||||||
|
)
|
||||||
|
AND download_id IS NOT NULL
|
||||||
|
""")
|
||||||
|
|
||||||
def get_database_path(self) -> str:
|
def get_database_path(self) -> str:
|
||||||
"""Return the resolved database file path."""
|
"""Return the resolved database file path."""
|
||||||
return self._db_path
|
return self._db_path
|
||||||
@@ -390,7 +424,7 @@ class DownloadQueueService:
|
|||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT 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, status, error, file_path,
|
||||||
bytes_downloaded, total_bytes, completed_at
|
bytes_downloaded, total_bytes, completed_at
|
||||||
@@ -547,17 +581,27 @@ class DownloadQueueService:
|
|||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def delete_history_item(self, id: int) -> bool:
|
async def delete_history_item(
|
||||||
"""Delete a single history entry by its *id*.
|
self, id: Optional[int] = None, download_id: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Delete a single history entry by *download_id* (preferred) or *id*.
|
||||||
|
|
||||||
Returns ``True`` if a row was deleted.
|
Returns ``True`` if a row was deleted.
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
|
if download_id:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"DELETE FROM download_history WHERE download_id = ?",
|
||||||
|
(download_id,),
|
||||||
|
)
|
||||||
|
elif id is not None:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"DELETE FROM download_history WHERE id = ?",
|
"DELETE FROM download_history WHERE id = ?",
|
||||||
(id,),
|
(id,),
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
@@ -614,21 +658,34 @@ class DownloadQueueService:
|
|||||||
# Retry
|
# Retry
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def retry_from_history(self, item_id: int) -> Optional[dict[str, Any]]:
|
async def retry_from_history(
|
||||||
|
self,
|
||||||
|
item_id: Optional[int] = None,
|
||||||
|
download_id: Optional[str] = None,
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
"""Re-queue a failed or canceled download from history.
|
"""Re-queue a failed or canceled download from history.
|
||||||
|
|
||||||
Looks up the history record by its primary key. If the status is
|
Looks up the history record by *download_id* (preferred) or
|
||||||
``failed`` or ``canceled`` a new queue entry is created with the
|
*item_id*. If the status is ``failed`` or ``canceled`` a new
|
||||||
same model metadata and a fresh download id, and the original
|
queue entry is created with the same model metadata and a fresh
|
||||||
history entry is **deleted** to prevent exponential growth when
|
download id, and the original history entry is **deleted** to
|
||||||
the retried item is later canceled or fails again and re-retried.
|
prevent exponential growth when the retried item is later
|
||||||
|
canceled or fails again and re-retried.
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
|
if download_id:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM download_history WHERE download_id = ?",
|
||||||
|
(download_id,),
|
||||||
|
).fetchone()
|
||||||
|
elif item_id is not None:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM download_history WHERE id = ?",
|
"SELECT * FROM download_history WHERE id = ?",
|
||||||
(item_id,),
|
(item_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
status = str(row["status"])
|
status = str(row["status"])
|
||||||
@@ -660,7 +717,7 @@ class DownloadQueueService:
|
|||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM download_history WHERE id = ?",
|
"DELETE FROM download_history WHERE id = ?",
|
||||||
(item_id,),
|
(row["id"],),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
queued = conn.execute(
|
queued = conn.execute(
|
||||||
|
|||||||
@@ -270,13 +270,13 @@ class Downloader:
|
|||||||
|
|
||||||
Note: This is private and caller MUST hold self._session_lock.
|
Note: This is private and caller MUST hold self._session_lock.
|
||||||
"""
|
"""
|
||||||
# Close existing session if any
|
# Snapshot and clear old session reference before creating the new
|
||||||
if self._session is not None:
|
# one. This ensures self._session is always valid (or None, which
|
||||||
try:
|
# triggers a fresh creation) and avoids a race where concurrent
|
||||||
await self._session.close()
|
# requests hold a reference to a session whose connector has been
|
||||||
except Exception as e: # pragma: no cover
|
# torn down by a premature close() call — the root cause of the
|
||||||
logger.warning(f"Error closing previous session: {e}")
|
# intermittent "NoneType has no attribute connect" crash.
|
||||||
finally:
|
old_session = self._session
|
||||||
self._session = None
|
self._session = None
|
||||||
|
|
||||||
# Check for app-level proxy settings
|
# Check for app-level proxy settings
|
||||||
@@ -372,6 +372,13 @@ class Downloader:
|
|||||||
self._proxy_url = proxy_url
|
self._proxy_url = proxy_url
|
||||||
self._session_created_at = datetime.now()
|
self._session_created_at = datetime.now()
|
||||||
|
|
||||||
|
# Close the previous session now that the replacement is live.
|
||||||
|
if old_session is not None:
|
||||||
|
try:
|
||||||
|
await old_session.close()
|
||||||
|
except Exception as e: # pragma: no cover
|
||||||
|
logger.warning(f"Error closing previous session: {e}")
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s",
|
"Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s",
|
||||||
bool(proxy_url),
|
bool(proxy_url),
|
||||||
@@ -753,6 +760,7 @@ class Downloader:
|
|||||||
else:
|
else:
|
||||||
resume_offset = 0
|
resume_offset = 0
|
||||||
total_size = 0
|
total_size = 0
|
||||||
|
async with self._session_lock:
|
||||||
await self._create_session()
|
await self._create_session()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -843,6 +851,7 @@ class Downloader:
|
|||||||
logger.info(f"Will resume from byte {resume_offset}")
|
logger.info(f"Will resume from byte {resume_offset}")
|
||||||
|
|
||||||
# Refresh session to get new connection
|
# Refresh session to get new connection
|
||||||
|
async with self._session_lock:
|
||||||
await self._create_session()
|
await self._create_session()
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -566,18 +566,52 @@ class LLMService:
|
|||||||
if effective_max is None:
|
if effective_max is None:
|
||||||
effective_max = 4096
|
effective_max = 4096
|
||||||
|
|
||||||
|
# Use json_schema (not json_object) for broader provider compatibility:
|
||||||
|
# LM Studio and some other OpenAI-compatible servers reject
|
||||||
|
# json_object but accept json_schema. {"type": "object"} is
|
||||||
|
# functionally equivalent — it accepts any JSON object without
|
||||||
|
# constraining specific fields.
|
||||||
|
response_format = {
|
||||||
|
"type": "json_schema",
|
||||||
|
"json_schema": {
|
||||||
|
"name": "metadata",
|
||||||
|
"schema": {"type": "object"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
result = await self.chat_completion(
|
result = await self.chat_completion(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
model=model,
|
model=model,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
response_format={"type": "json_object"},
|
response_format=response_format,
|
||||||
|
max_tokens=effective_max,
|
||||||
|
)
|
||||||
|
except LLMResponseError as e:
|
||||||
|
# Only fall back when the provider rejects the response_format
|
||||||
|
# type value (e.g. "'response_format.type' must be..."). Avoid
|
||||||
|
# catching unrelated 400 errors whose body happens to mention
|
||||||
|
# "response_format" (e.g. "model does not support
|
||||||
|
# response_format restrictions on this endpoint").
|
||||||
|
if "'response_format.type'" not in str(e).lower():
|
||||||
|
raise
|
||||||
|
logger.info(
|
||||||
|
"Provider rejected response_format, retrying without it. "
|
||||||
|
"Falling back to prompt-only JSON mode. Error: %s",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
result = await self.chat_completion(
|
||||||
|
messages=messages,
|
||||||
|
model=model,
|
||||||
|
temperature=temperature,
|
||||||
|
response_format=None,
|
||||||
max_tokens=effective_max,
|
max_tokens=effective_max,
|
||||||
)
|
)
|
||||||
|
|
||||||
content = result.get("content", "") or ""
|
content = result.get("content", "") or ""
|
||||||
if not content:
|
if not content:
|
||||||
raise LLMResponseError(
|
raise LLMResponseError(
|
||||||
"LLM returned empty content in json_object mode. "
|
"LLM returned empty content. "
|
||||||
f"Raw response: {json.dumps(result)[:500]}"
|
f"Raw response: {json.dumps(result)[:500]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ from .service_registry import ServiceRegistry
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_PROVIDER_DISPLAY_NAMES = {
|
||||||
|
"civitai_api": "CivitAI",
|
||||||
|
"civarchive_api": "CivArchive",
|
||||||
|
"sqlite": "Archive DB",
|
||||||
|
}
|
||||||
|
|
||||||
|
_PRESET_PROVIDER_ORDERS = {
|
||||||
|
"civitai_archive_sqlite": ["civitai_api", "civarchive_api", "sqlite"],
|
||||||
|
"civitai_sqlite_archive": ["civitai_api", "sqlite", "civarchive_api"],
|
||||||
|
}
|
||||||
|
|
||||||
async def initialize_metadata_providers():
|
async def initialize_metadata_providers():
|
||||||
"""Initialize and configure all metadata providers based on settings"""
|
"""Initialize and configure all metadata providers based on settings"""
|
||||||
provider_manager = await ModelMetadataProviderManager.get_instance()
|
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||||
@@ -26,6 +37,8 @@ async def initialize_metadata_providers():
|
|||||||
# Get settings
|
# Get settings
|
||||||
settings_manager = get_settings_manager()
|
settings_manager = get_settings_manager()
|
||||||
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||||
|
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
|
||||||
|
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
|
||||||
|
|
||||||
providers = []
|
providers = []
|
||||||
|
|
||||||
@@ -59,7 +72,11 @@ async def initialize_metadata_providers():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize Civitai API metadata provider: {e}")
|
logger.error(f"Failed to initialize Civitai API metadata provider: {e}")
|
||||||
|
|
||||||
# Register CivArchive provider, and all add to fallback providers
|
# Register CivArchive provider when enabled. Civitai API is always
|
||||||
|
# preferred (better metadata); CivArchive mainly recovers metadata for
|
||||||
|
# models deleted from Civitai, so it can be turned off to avoid its long
|
||||||
|
# rate-limit windows entirely.
|
||||||
|
if enable_civarchive_api:
|
||||||
try:
|
try:
|
||||||
civarchive_client = await ServiceRegistry.get_civarchive_client()
|
civarchive_client = await ServiceRegistry.get_civarchive_client()
|
||||||
civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client)
|
civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client)
|
||||||
@@ -68,18 +85,35 @@ async def initialize_metadata_providers():
|
|||||||
logger.debug("CivArchive metadata provider registered (also included in fallback)")
|
logger.debug("CivArchive metadata provider registered (also included in fallback)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
|
logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
|
||||||
|
else:
|
||||||
|
logger.debug("CivArchive metadata provider disabled by setting 'enable_civarchive_api'")
|
||||||
|
|
||||||
|
# Preset fallback orderings (see module-level _PRESET_PROVIDER_ORDERS).
|
||||||
|
# civitai_api is always first (better metadata); the remaining providers
|
||||||
|
# are arranged by the configured preset. Providers that are not
|
||||||
|
# registered (disabled/unavailable) are simply skipped, so each preset
|
||||||
|
# degrades gracefully.
|
||||||
|
desired_order = _PRESET_PROVIDER_ORDERS.get(
|
||||||
|
provider_order, _PRESET_PROVIDER_ORDERS["civitai_archive_sqlite"]
|
||||||
|
)
|
||||||
|
|
||||||
# Set up fallback provider based on available providers
|
# Set up fallback provider based on available providers
|
||||||
if len(providers) > 1:
|
if len(providers) > 1:
|
||||||
# Always use Civitai API (it has better metadata), then CivArchive API, then Archive DB
|
|
||||||
ordered_providers: list[tuple[str, ModelMetadataProvider]] = []
|
ordered_providers: list[tuple[str, ModelMetadataProvider]] = []
|
||||||
ordered_providers.extend([p for p in providers if p[0] == 'civitai_api'])
|
for name in desired_order:
|
||||||
ordered_providers.extend([p for p in providers if p[0] == 'civarchive_api'])
|
ordered_providers.extend([p for p in providers if p[0] == name])
|
||||||
ordered_providers.extend([p for p in providers if p[0] == 'sqlite'])
|
# Include any provider not covered by the preset (defensive) at the end
|
||||||
|
for p in providers:
|
||||||
|
if p not in ordered_providers:
|
||||||
|
ordered_providers.append(p)
|
||||||
|
|
||||||
if ordered_providers:
|
if ordered_providers:
|
||||||
fallback_provider = FallbackMetadataProvider(ordered_providers)
|
fallback_provider = FallbackMetadataProvider(ordered_providers)
|
||||||
provider_manager.register_provider('fallback', fallback_provider, is_default=True)
|
provider_manager.register_provider('fallback', fallback_provider, is_default=True)
|
||||||
|
logger.debug(
|
||||||
|
"Metadata fallback provider order: %s",
|
||||||
|
", ".join(name for name, _ in ordered_providers),
|
||||||
|
)
|
||||||
elif len(providers) == 1:
|
elif len(providers) == 1:
|
||||||
# Only one provider available, set it as default
|
# Only one provider available, set it as default
|
||||||
provider_name, provider = providers[0]
|
provider_name, provider = providers[0]
|
||||||
@@ -96,11 +130,30 @@ async def update_metadata_providers():
|
|||||||
# Get current settings
|
# Get current settings
|
||||||
settings_manager = get_settings_manager()
|
settings_manager = get_settings_manager()
|
||||||
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||||
|
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
|
||||||
|
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
|
||||||
|
|
||||||
# Reinitialize all providers with new settings
|
# Reinitialize all providers with new settings
|
||||||
provider_manager = await initialize_metadata_providers()
|
provider_manager = await initialize_metadata_providers()
|
||||||
|
|
||||||
logger.info(f"Updated metadata providers, archive_db enabled: {enable_archive_db}")
|
# Build effective provider chain for logging (use actually-registered
|
||||||
|
# providers, not just settings, so a failed init is reflected correctly)
|
||||||
|
registered = set(provider_manager.providers.keys())
|
||||||
|
desired = _PRESET_PROVIDER_ORDERS.get(
|
||||||
|
provider_order, _PRESET_PROVIDER_ORDERS["civitai_archive_sqlite"]
|
||||||
|
)
|
||||||
|
chain = " → ".join(
|
||||||
|
_PROVIDER_DISPLAY_NAMES[p]
|
||||||
|
for p in desired
|
||||||
|
if p in registered and p in _PROVIDER_DISPLAY_NAMES
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Updated metadata providers: archive_db=%s, civarchive_api=%s, chain=%s",
|
||||||
|
enable_archive_db,
|
||||||
|
enable_civarchive_api,
|
||||||
|
chain,
|
||||||
|
)
|
||||||
return provider_manager
|
return provider_manager
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update metadata providers: {e}")
|
logger.error(f"Failed to update metadata providers: {e}")
|
||||||
|
|||||||
+21
-12
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
@@ -38,8 +39,8 @@ class ModelCache:
|
|||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
# Cache for last sort: (sort_key, order) -> sorted list
|
# Cache for last sort: (sort_key, order, seed) -> sorted list
|
||||||
self._last_sort: Tuple[str, str] = (None, None)
|
self._last_sort: Tuple[Optional[str], str, Optional[str]] = (None, "asc", None)
|
||||||
self._last_sorted_data: List[Dict] = []
|
self._last_sorted_data: List[Dict] = []
|
||||||
self._normalize_raw_data()
|
self._normalize_raw_data()
|
||||||
self.name_display_mode = self._normalize_display_mode(self.name_display_mode)
|
self.name_display_mode = self._normalize_display_mode(self.name_display_mode)
|
||||||
@@ -203,9 +204,9 @@ class ModelCache:
|
|||||||
async def resort(self):
|
async def resort(self):
|
||||||
"""Resort cached data according to last sort mode if set"""
|
"""Resort cached data according to last sort mode if set"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if self._last_sort != (None, None):
|
if self._last_sort[0] is not None:
|
||||||
sort_key, order = self._last_sort
|
sort_key, order, seed = self._last_sort
|
||||||
sorted_data = self._sort_data(self.raw_data, sort_key, order)
|
sorted_data = self._sort_data(self.raw_data, sort_key, order, seed)
|
||||||
self._last_sorted_data = sorted_data
|
self._last_sorted_data = sorted_data
|
||||||
# Update folder list
|
# Update folder list
|
||||||
# else: do nothing
|
# else: do nothing
|
||||||
@@ -218,7 +219,7 @@ class ModelCache:
|
|||||||
self.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
self.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||||
self.rebuild_version_index()
|
self.rebuild_version_index()
|
||||||
|
|
||||||
def _sort_data(self, data: List[Dict], sort_key: str, order: str) -> List[Dict]:
|
def _sort_data(self, data: List[Dict], sort_key: str, order: str, seed: Optional[str] = None) -> List[Dict]:
|
||||||
"""Sort data by sort_key and order"""
|
"""Sort data by sort_key and order"""
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
reverse = (order == 'desc')
|
reverse = (order == 'desc')
|
||||||
@@ -265,6 +266,13 @@ class ModelCache:
|
|||||||
),
|
),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
|
elif sort_key == 'random':
|
||||||
|
# Random shuffle seeded for stable pagination: the same seed
|
||||||
|
# always yields the same order, so successive page requests
|
||||||
|
# stay consistent while browsing.
|
||||||
|
rng = random.Random(seed or 'random')
|
||||||
|
result = list(data)
|
||||||
|
rng.shuffle(result)
|
||||||
elif sort_key == 'versions_count':
|
elif sort_key == 'versions_count':
|
||||||
# Pre-dedup sort: fall back to name sort.
|
# Pre-dedup sort: fall back to name sort.
|
||||||
# Actual re-sort by version_count happens in get_paginated_data after dedup.
|
# Actual re-sort by version_count happens in get_paginated_data after dedup.
|
||||||
@@ -285,15 +293,16 @@ class ModelCache:
|
|||||||
logger.debug("ModelCache._sort_data(%s, %s) for %d items took %.3fs", sort_key, order, len(data), duration)
|
logger.debug("ModelCache._sort_data(%s, %s) for %d items took %.3fs", sort_key, order, len(data), duration)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc') -> List[Dict]:
|
async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc', seed: Optional[str] = None) -> List[Dict]:
|
||||||
"""Get sorted data by sort_key and order, using cache if possible"""
|
"""Get sorted data by sort_key and order, using cache if possible"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if (sort_key, order) == self._last_sort:
|
cache_key = (sort_key, order, seed)
|
||||||
|
if cache_key == self._last_sort:
|
||||||
return self._last_sorted_data
|
return self._last_sorted_data
|
||||||
|
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
sorted_data = self._sort_data(self.raw_data, sort_key, order)
|
sorted_data = self._sort_data(self.raw_data, sort_key, order, seed)
|
||||||
self._last_sort = (sort_key, order)
|
self._last_sort = cache_key
|
||||||
self._last_sorted_data = sorted_data
|
self._last_sorted_data = sorted_data
|
||||||
|
|
||||||
duration = time.perf_counter() - start_time
|
duration = time.perf_counter() - start_time
|
||||||
@@ -313,8 +322,8 @@ class ModelCache:
|
|||||||
self.name_display_mode = normalized
|
self.name_display_mode = normalized
|
||||||
|
|
||||||
if self._last_sort[0] == 'name':
|
if self._last_sort[0] == 'name':
|
||||||
sort_key, order = self._last_sort
|
sort_key, order, seed = self._last_sort
|
||||||
self._last_sorted_data = self._sort_data(self.raw_data, sort_key, order)
|
self._last_sorted_data = self._sort_data(self.raw_data, sort_key, order, seed)
|
||||||
|
|
||||||
async def update_preview_url(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool:
|
async def update_preview_url(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool:
|
||||||
"""Update preview_url for a specific model in all cached data
|
"""Update preview_url for a specific model in all cached data
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
|
|||||||
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
||||||
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
|
from ..services.model_lifecycle_service import _require_path_in_library_roots
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -493,6 +494,9 @@ class ModelMoveService:
|
|||||||
Dictionary with move result
|
Dictionary with move result
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
_require_path_in_library_roots(file_path, self.scanner, label="Source path")
|
||||||
|
_require_path_in_library_roots(target_path, self.scanner, label="Target path")
|
||||||
|
|
||||||
if use_default_paths:
|
if use_default_paths:
|
||||||
# Find the model in cache to get metadata
|
# Find the model in cache to get metadata
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|||||||
@@ -48,6 +48,36 @@ async def delete_model_artifacts(
|
|||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
def _require_path_in_library_roots(file_path: str, scanner, *, label: str = "path") -> None:
|
||||||
|
"""Raise ``ValueError`` if *file_path* is not inside a configured model root.
|
||||||
|
|
||||||
|
Uses ``os.path.abspath()`` (NOT ``realpath``) to resolve ``..`` and ``.``
|
||||||
|
while preserving symlinks — this keeps the check in business-path space.
|
||||||
|
Skips when the scanner does not expose ``get_model_roots`` or the list
|
||||||
|
is empty.
|
||||||
|
"""
|
||||||
|
|
||||||
|
roots = None
|
||||||
|
if hasattr(scanner, "get_model_roots"):
|
||||||
|
try:
|
||||||
|
roots = scanner.get_model_roots()
|
||||||
|
except NotImplementedError:
|
||||||
|
roots = None
|
||||||
|
if not roots:
|
||||||
|
return
|
||||||
|
|
||||||
|
resolved = os.path.abspath(os.path.normpath(file_path))
|
||||||
|
|
||||||
|
for root in roots:
|
||||||
|
root_resolved = os.path.abspath(os.path.normpath(root))
|
||||||
|
if resolved == root_resolved or resolved.startswith(root_resolved + os.sep):
|
||||||
|
return
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
f"{label} '{file_path}' is outside configured library directories"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ModelLifecycleService:
|
class ModelLifecycleService:
|
||||||
"""Co-ordinate destructive and mutating model operations."""
|
"""Co-ordinate destructive and mutating model operations."""
|
||||||
|
|
||||||
@@ -74,6 +104,8 @@ class ModelLifecycleService:
|
|||||||
if not file_path:
|
if not file_path:
|
||||||
raise ValueError("Model path is required")
|
raise ValueError("Model path is required")
|
||||||
|
|
||||||
|
_require_path_in_library_roots(file_path, self._scanner, label="File path")
|
||||||
|
|
||||||
cache = await self._scanner.get_cached_data()
|
cache = await self._scanner.get_cached_data()
|
||||||
|
|
||||||
cached_entry = None
|
cached_entry = None
|
||||||
@@ -182,6 +214,8 @@ class ModelLifecycleService:
|
|||||||
if not file_path:
|
if not file_path:
|
||||||
raise ValueError("Model path is required")
|
raise ValueError("Model path is required")
|
||||||
|
|
||||||
|
_require_path_in_library_roots(file_path, self._scanner, label="File path")
|
||||||
|
|
||||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||||
metadata = await self._metadata_loader(metadata_path)
|
metadata = await self._metadata_loader(metadata_path)
|
||||||
metadata["exclude"] = True
|
metadata["exclude"] = True
|
||||||
@@ -229,6 +263,8 @@ class ModelLifecycleService:
|
|||||||
if not file_path:
|
if not file_path:
|
||||||
raise ValueError("Model path is required")
|
raise ValueError("Model path is required")
|
||||||
|
|
||||||
|
_require_path_in_library_roots(file_path, self._scanner, label="File path")
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
raise ValueError("Model file does not exist")
|
raise ValueError("Model file does not exist")
|
||||||
|
|
||||||
@@ -270,6 +306,9 @@ class ModelLifecycleService:
|
|||||||
if not file_paths:
|
if not file_paths:
|
||||||
raise ValueError("No file paths provided for deletion")
|
raise ValueError("No file paths provided for deletion")
|
||||||
|
|
||||||
|
for path in file_paths:
|
||||||
|
_require_path_in_library_roots(path, self._scanner, label="File path")
|
||||||
|
|
||||||
return await self._scanner.bulk_delete_models(file_paths)
|
return await self._scanner.bulk_delete_models(file_paths)
|
||||||
|
|
||||||
async def rename_model(
|
async def rename_model(
|
||||||
@@ -280,6 +319,8 @@ class ModelLifecycleService:
|
|||||||
if not file_path or not new_file_name:
|
if not file_path or not new_file_name:
|
||||||
raise ValueError("File path and new file name are required")
|
raise ValueError("File path and new file name are required")
|
||||||
|
|
||||||
|
_require_path_in_library_roots(file_path, self._scanner, label="File path")
|
||||||
|
|
||||||
invalid_chars = {"/", "\\", ":", "*", "?", '"', "<", ">", "|"}
|
invalid_chars = {"/", "\\", ":", "*", "?", '"', "<", ">", "|"}
|
||||||
if any(char in new_file_name for char in invalid_chars):
|
if any(char in new_file_name for char in invalid_chars):
|
||||||
raise ValueError("Invalid characters in file name")
|
raise ValueError("Invalid characters in file name")
|
||||||
|
|||||||
@@ -143,10 +143,18 @@ class ModelMetadataProvider(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict]:
|
||||||
"""Fetch models owned by the specified user"""
|
"""Fetch one page of models owned by the specified user.
|
||||||
|
|
||||||
|
Returns ``{"items": [...], "nextCursor": <str|None>}`` on success,
|
||||||
|
or None when unsupported/failed. ``cursor`` continues a previous page.
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def get_creator_model_count(self, username: str) -> Optional[int]:
|
||||||
|
"""Published model count for the user; None when unsupported."""
|
||||||
|
return None
|
||||||
|
|
||||||
class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
||||||
"""Provider that uses Civitai API for metadata"""
|
"""Provider that uses Civitai API for metadata"""
|
||||||
|
|
||||||
@@ -175,8 +183,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
|||||||
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], Optional[str]]:
|
||||||
return await self.client.get_model_version_info(version_id)
|
return await self.client.get_model_version_info(version_id)
|
||||||
|
|
||||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict]:
|
||||||
return await self.client.get_user_models(username)
|
return await self.client.get_user_models(username, cursor)
|
||||||
|
|
||||||
|
async def get_creator_model_count(self, username: str) -> Optional[int]:
|
||||||
|
return await self.client.get_creator_model_count(username)
|
||||||
|
|
||||||
class CivArchiveModelMetadataProvider(ModelMetadataProvider):
|
class CivArchiveModelMetadataProvider(ModelMetadataProvider):
|
||||||
"""Provider that uses CivArchive API for metadata"""
|
"""Provider that uses CivArchive API for metadata"""
|
||||||
@@ -196,7 +207,7 @@ class CivArchiveModelMetadataProvider(ModelMetadataProvider):
|
|||||||
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], Optional[str]]:
|
||||||
return await self.client.get_model_version_info(version_id)
|
return await self.client.get_model_version_info(version_id)
|
||||||
|
|
||||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict]:
|
||||||
"""Not supported by CivArchive provider"""
|
"""Not supported by CivArchive provider"""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -347,7 +358,7 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
|
|||||||
version_data = await self._get_version_with_model_data(db, model_id, version_id)
|
version_data = await self._get_version_with_model_data(db, model_id, version_id)
|
||||||
return version_data, None
|
return version_data, None
|
||||||
|
|
||||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict]:
|
||||||
"""Listing models by username is not supported for archive database"""
|
"""Listing models by username is not supported for archive database"""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -602,13 +613,14 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict]:
|
||||||
for provider, label in self._iter_providers():
|
for provider, label in self._iter_providers():
|
||||||
try:
|
try:
|
||||||
result = await self._call_with_rate_limit(
|
result = await self._call_with_rate_limit(
|
||||||
label,
|
label,
|
||||||
provider.get_user_models,
|
provider.get_user_models,
|
||||||
username,
|
username,
|
||||||
|
cursor=cursor,
|
||||||
)
|
)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
@@ -624,6 +636,19 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def get_creator_model_count(self, username: str) -> Optional[int]:
|
||||||
|
for provider, label in self._iter_providers():
|
||||||
|
try:
|
||||||
|
result = await provider.get_creator_model_count(username)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"Provider %s failed for get_creator_model_count: %s", label, e
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
def _iter_providers(self):
|
def _iter_providers(self):
|
||||||
return zip(self.providers, self._provider_labels)
|
return zip(self.providers, self._provider_labels)
|
||||||
|
|
||||||
@@ -704,13 +729,17 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
|
|||||||
version_id,
|
version_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict]:
|
||||||
return await self._rate_limit_helper.run(
|
return await self._rate_limit_helper.run(
|
||||||
self._label,
|
self._label,
|
||||||
self._provider.get_user_models,
|
self._provider.get_user_models,
|
||||||
username,
|
username,
|
||||||
|
cursor=cursor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def get_creator_model_count(self, username: str) -> Optional[int]:
|
||||||
|
return await self._provider.get_creator_model_count(username)
|
||||||
|
|
||||||
class ModelMetadataProviderManager:
|
class ModelMetadataProviderManager:
|
||||||
"""Manager for selecting and using model metadata providers"""
|
"""Manager for selecting and using model metadata providers"""
|
||||||
|
|
||||||
@@ -776,10 +805,20 @@ class ModelMetadataProviderManager:
|
|||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
|
async def get_user_models(
|
||||||
"""Fetch models owned by the specified user"""
|
self,
|
||||||
|
username: str,
|
||||||
|
provider_name: str = None,
|
||||||
|
cursor: Optional[str] = None,
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
"""Fetch one page of models owned by the specified user"""
|
||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
return await provider.get_user_models(username)
|
return await provider.get_user_models(username, cursor)
|
||||||
|
|
||||||
|
async def get_creator_model_count(self, username: str, provider_name: str = None) -> Optional[int]:
|
||||||
|
"""Best-effort published model count for the specified user"""
|
||||||
|
provider = self._get_provider(provider_name)
|
||||||
|
return await provider.get_creator_model_count(username)
|
||||||
|
|
||||||
def _get_provider(self, provider_name: str = None) -> ModelMetadataProvider:
|
def _get_provider(self, provider_name: str = None) -> ModelMetadataProvider:
|
||||||
"""Get provider by name or default provider"""
|
"""Get provider by name or default provider"""
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ class SortParams:
|
|||||||
|
|
||||||
key: str
|
key: str
|
||||||
order: str
|
order: str
|
||||||
|
seed: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -116,7 +117,7 @@ class ModelCacheRepository:
|
|||||||
async def fetch_sorted(self, params: SortParams) -> List[Dict[str, Any]]:
|
async def fetch_sorted(self, params: SortParams) -> List[Dict[str, Any]]:
|
||||||
"""Fetch cached data pre-sorted according to ``params``."""
|
"""Fetch cached data pre-sorted according to ``params``."""
|
||||||
cache = await self.get_cache()
|
cache = await self.get_cache()
|
||||||
return await cache.get_sorted_data(params.key, params.order)
|
return await cache.get_sorted_data(params.key, params.order, params.seed)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_sort(sort_by: str) -> SortParams:
|
def parse_sort(sort_by: str) -> SortParams:
|
||||||
@@ -132,10 +133,17 @@ class ModelCacheRepository:
|
|||||||
sort_key = sort_by.strip().lower() or "name"
|
sort_key = sort_by.strip().lower() or "name"
|
||||||
order = "asc"
|
order = "asc"
|
||||||
|
|
||||||
if order not in ("asc", "desc"):
|
seed = None
|
||||||
|
if sort_key == "random":
|
||||||
|
# Random sort: the portion after ':' is the shuffle seed.
|
||||||
|
# A stable seed keeps paginated requests consistent; order is
|
||||||
|
# meaningless for a random shuffle.
|
||||||
|
seed = order if order and order not in ("asc", "desc") else None
|
||||||
|
order = "asc"
|
||||||
|
elif order not in ("asc", "desc"):
|
||||||
order = "asc"
|
order = "asc"
|
||||||
|
|
||||||
return SortParams(key=sort_key, order=order)
|
return SortParams(key=sort_key, order=order, seed=seed)
|
||||||
|
|
||||||
|
|
||||||
class ModelFilterSet:
|
class ModelFilterSet:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from ..utils.metadata_manager import MetadataManager
|
|||||||
from ..utils.civitai_utils import resolve_license_info
|
from ..utils.civitai_utils import resolve_license_info
|
||||||
from .model_cache import ModelCache
|
from .model_cache import ModelCache
|
||||||
from .model_hash_index import ModelHashIndex
|
from .model_hash_index import ModelHashIndex
|
||||||
from .model_lifecycle_service import delete_model_artifacts
|
from .model_lifecycle_service import delete_model_artifacts, _require_path_in_library_roots
|
||||||
from .service_registry import ServiceRegistry
|
from .service_registry import ServiceRegistry
|
||||||
from .websocket_manager import ws_manager
|
from .websocket_manager import ws_manager
|
||||||
from .persistent_model_cache import get_persistent_cache
|
from .persistent_model_cache import get_persistent_cache
|
||||||
@@ -927,6 +927,25 @@ class ModelScanner:
|
|||||||
# Update cache data
|
# Update cache data
|
||||||
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
|
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
|
||||||
|
|
||||||
|
dedup_removed = 0
|
||||||
|
seen_paths: set = set()
|
||||||
|
deduped: list = []
|
||||||
|
for item in reversed(self._cache.raw_data):
|
||||||
|
path = item.get('file_path', '')
|
||||||
|
if path not in seen_paths:
|
||||||
|
seen_paths.add(path)
|
||||||
|
deduped.append(item)
|
||||||
|
else:
|
||||||
|
for tag in item.get('tags', []):
|
||||||
|
if tag in self._tags_count:
|
||||||
|
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||||
|
if self._tags_count[tag] == 0:
|
||||||
|
del self._tags_count[tag]
|
||||||
|
dedup_removed += 1
|
||||||
|
if dedup_removed > 0:
|
||||||
|
self._cache.raw_data = list(reversed(deduped))
|
||||||
|
total_removed += dedup_removed
|
||||||
|
|
||||||
# Resort cache if changes were made
|
# Resort cache if changes were made
|
||||||
if total_added > 0 or total_removed > 0:
|
if total_added > 0 or total_removed > 0:
|
||||||
# Update folders list
|
# Update folders list
|
||||||
@@ -1352,18 +1371,25 @@ class ModelScanner:
|
|||||||
# Update folder in metadata
|
# Update folder in metadata
|
||||||
metadata_dict['folder'] = folder
|
metadata_dict['folder'] = folder
|
||||||
|
|
||||||
# Add to cache
|
file_path = metadata_dict.get('file_path', '')
|
||||||
|
if file_path:
|
||||||
|
old_entries = [item for item in self._cache.raw_data if item.get('file_path') == file_path]
|
||||||
|
for old_entry in old_entries:
|
||||||
|
for tag in old_entry.get('tags', []):
|
||||||
|
if tag in self._tags_count:
|
||||||
|
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||||
|
if self._tags_count[tag] == 0:
|
||||||
|
del self._tags_count[tag]
|
||||||
|
self._hash_index.remove_by_path(file_path)
|
||||||
|
self._cache.raw_data = [item for item in self._cache.raw_data if item.get('file_path') != file_path]
|
||||||
|
|
||||||
|
for tag in metadata_dict.get('tags', []):
|
||||||
|
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
self._cache.raw_data.append(metadata_dict)
|
self._cache.raw_data.append(metadata_dict)
|
||||||
self._cache.add_to_version_index(metadata_dict)
|
|
||||||
|
|
||||||
# Resort cache data
|
|
||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
|
|
||||||
# Update folders list
|
|
||||||
all_folders = set(self._cache.folders)
|
|
||||||
all_folders.add(folder)
|
|
||||||
self._cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
|
||||||
|
|
||||||
# Update the hash index
|
# Update the hash index
|
||||||
self._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
|
self._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
|
||||||
await self._persist_current_cache()
|
await self._persist_current_cache()
|
||||||
@@ -1395,6 +1421,9 @@ class ModelScanner:
|
|||||||
base_name = os.path.splitext(os.path.basename(source_path))[0]
|
base_name = os.path.splitext(os.path.basename(source_path))[0]
|
||||||
source_dir = os.path.dirname(source_path)
|
source_dir = os.path.dirname(source_path)
|
||||||
|
|
||||||
|
_require_path_in_library_roots(source_path, self, label="Source path")
|
||||||
|
_require_path_in_library_roots(target_path, self, label="Target path")
|
||||||
|
|
||||||
os.makedirs(target_path, exist_ok=True)
|
os.makedirs(target_path, exist_ok=True)
|
||||||
|
|
||||||
def get_source_hash():
|
def get_source_hash():
|
||||||
@@ -1566,6 +1595,218 @@ class ModelScanner:
|
|||||||
|
|
||||||
return cache_entry if metadata else True
|
return cache_entry if metadata else True
|
||||||
|
|
||||||
|
async def sync_cache_from_metadata(
|
||||||
|
self, file_path: str, metadata_dict: Dict[str, Any]
|
||||||
|
) -> bool:
|
||||||
|
"""Opportunistically sync in-memory and persistent caches from metadata.
|
||||||
|
|
||||||
|
Builds a prospective cache entry from *metadata_dict* (deserialized
|
||||||
|
``.metadata.json`` content) and compares it against the current cache
|
||||||
|
entry. When the two are already identical this method returns
|
||||||
|
``False`` without touching anything — avoiding the overhead of
|
||||||
|
``update_single_model_cache``, which always removes and re-inserts
|
||||||
|
the entry, triggers a full resort, and persists via the heavyweight
|
||||||
|
``save_cache()``.
|
||||||
|
|
||||||
|
When differences are detected the update is applied **in-place** with
|
||||||
|
targeted operations:
|
||||||
|
|
||||||
|
* The existing ``raw_data`` entry is modified rather than removed and
|
||||||
|
re-appended (O(1) instead of O(n)).
|
||||||
|
* Tag counts and the hash index are updated incrementally.
|
||||||
|
* The version index is rebuilt only for the affected entry.
|
||||||
|
* ``resort()`` is called **only** when a sort-relevant field changed
|
||||||
|
(``model_name`` / ``file_name`` for name-sort, ``modified`` for
|
||||||
|
date-sort, ``size`` for size-sort).
|
||||||
|
* The persistent (SQLite) cache receives a targeted single-row update
|
||||||
|
via :meth:`PersistentModelCache.update_single_model` rather than a
|
||||||
|
full-table ``save_cache()``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` if any cache update was performed, ``False`` if the
|
||||||
|
caches were already in sync.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This is a **best-effort** operation. Failures are logged but
|
||||||
|
never propagated — callers should fire-and-forget via
|
||||||
|
:func:`asyncio.create_task`.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self._sync_cache_from_metadata_impl(
|
||||||
|
file_path, metadata_dict
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"sync_cache_from_metadata failed for %s",
|
||||||
|
file_path,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _sync_cache_from_metadata_impl(
|
||||||
|
self, file_path: str, metadata_dict: Dict[str, Any]
|
||||||
|
) -> bool:
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
|
# Locate the existing cache entry -----------------------------------
|
||||||
|
existing_idx: Optional[int] = None
|
||||||
|
existing_entry: Optional[Dict[str, Any]] = None
|
||||||
|
for i, item in enumerate(cache.raw_data):
|
||||||
|
if item.get("file_path") == file_path:
|
||||||
|
existing_entry = item
|
||||||
|
existing_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
# Build the desired entry from metadata ------------------------------
|
||||||
|
folder_value = (
|
||||||
|
existing_entry.get("folder", "")
|
||||||
|
if existing_entry
|
||||||
|
else self._calculate_folder(file_path)
|
||||||
|
)
|
||||||
|
desired_entry = self._build_cache_entry(
|
||||||
|
metadata_dict,
|
||||||
|
folder=folder_value,
|
||||||
|
file_path_override=file_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure sha256 is populated (defensive — metadata should have it)
|
||||||
|
if (
|
||||||
|
not desired_entry.get("sha256")
|
||||||
|
and file_path
|
||||||
|
and os.path.exists(file_path)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
sha256 = await calculate_sha256(file_path)
|
||||||
|
if sha256:
|
||||||
|
desired_entry["sha256"] = sha256.lower()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Not in cache at all — delegate to the full update path ------------
|
||||||
|
if existing_entry is None:
|
||||||
|
result = await self.update_single_model_cache(
|
||||||
|
file_path, file_path, metadata_dict
|
||||||
|
)
|
||||||
|
return bool(result)
|
||||||
|
|
||||||
|
# Compare — skip everything if already in sync -----------------------
|
||||||
|
if not self._cache_entries_differ(existing_entry, desired_entry):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Re-validate: the cache may have been replaced concurrently
|
||||||
|
# (e.g. by _apply_scan_result). Use identity check, not equality,
|
||||||
|
# so we detect when the raw_data list was swapped out from under us.
|
||||||
|
if self._cache is None or not any(
|
||||||
|
item is existing_entry for item in self._cache.raw_data
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---- Differences detected: apply targeted, in-place updates --------
|
||||||
|
|
||||||
|
# Snapshot old values for delta computations
|
||||||
|
old_tags = list(existing_entry.get("tags") or [])
|
||||||
|
old_sha256: str = existing_entry.get("sha256", "") or ""
|
||||||
|
old_model_name: str = existing_entry.get("model_name", "") or ""
|
||||||
|
old_file_name: str = existing_entry.get("file_name", "") or ""
|
||||||
|
old_modified: float = float(existing_entry.get("modified", 0.0) or 0.0)
|
||||||
|
old_size: int = int(existing_entry.get("size", 0) or 0)
|
||||||
|
old_civitai = existing_entry.get("civitai")
|
||||||
|
|
||||||
|
# ---- In-place update of the cache entry ----
|
||||||
|
existing_entry.clear()
|
||||||
|
existing_entry.update(desired_entry)
|
||||||
|
|
||||||
|
# ---- Incremental tag count update ----
|
||||||
|
new_tags: set = set(desired_entry.get("tags") or [])
|
||||||
|
old_tag_set: set = set(old_tags)
|
||||||
|
for tag in old_tag_set - new_tags:
|
||||||
|
current = self._tags_count.get(tag, 0)
|
||||||
|
if current <= 1:
|
||||||
|
self._tags_count.pop(tag, None)
|
||||||
|
else:
|
||||||
|
self._tags_count[tag] = current - 1
|
||||||
|
for tag in new_tags - old_tag_set:
|
||||||
|
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
|
# ---- Incremental hash index update ----
|
||||||
|
new_sha = (desired_entry.get("sha256", "") or "").lower()
|
||||||
|
old_sha = (old_sha256 or "").lower()
|
||||||
|
if new_sha != old_sha:
|
||||||
|
if old_sha:
|
||||||
|
self._hash_index.remove_by_path(file_path)
|
||||||
|
if new_sha:
|
||||||
|
self._hash_index.add_entry(new_sha, file_path)
|
||||||
|
|
||||||
|
# ---- Incremental version index update ----
|
||||||
|
new_civitai = desired_entry.get("civitai")
|
||||||
|
if old_civitai != new_civitai:
|
||||||
|
temp_old = {
|
||||||
|
"file_path": file_path,
|
||||||
|
"file_name": old_file_name,
|
||||||
|
"civitai": old_civitai,
|
||||||
|
}
|
||||||
|
cache.remove_from_version_index(temp_old)
|
||||||
|
cache.add_to_version_index(existing_entry)
|
||||||
|
|
||||||
|
# ---- Conditional resort (only when sort-key fields changed) ----
|
||||||
|
need_resort = False
|
||||||
|
_last = cache._last_sort
|
||||||
|
sort_key: Optional[str] = _last[0] if _last[0] is not None else None
|
||||||
|
if sort_key == "name":
|
||||||
|
if (
|
||||||
|
old_model_name != desired_entry.get("model_name", "")
|
||||||
|
or old_file_name != desired_entry.get("file_name", "")
|
||||||
|
):
|
||||||
|
need_resort = True
|
||||||
|
elif sort_key == "date":
|
||||||
|
if old_modified != float(desired_entry.get("modified", 0.0) or 0.0):
|
||||||
|
need_resort = True
|
||||||
|
elif sort_key == "size":
|
||||||
|
if old_size != int(desired_entry.get("size", 0) or 0):
|
||||||
|
need_resort = True
|
||||||
|
|
||||||
|
if need_resort:
|
||||||
|
await cache.resort()
|
||||||
|
|
||||||
|
# ---- Targeted SQL update (single row, not full save_cache) ----
|
||||||
|
persistent = getattr(self, "_persistent_cache", None)
|
||||||
|
if persistent is not None:
|
||||||
|
old_item_for_sql: Dict[str, Any] = {
|
||||||
|
"file_path": file_path,
|
||||||
|
"tags": old_tags,
|
||||||
|
"sha256": old_sha256,
|
||||||
|
}
|
||||||
|
await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
persistent.update_single_model,
|
||||||
|
self.model_type,
|
||||||
|
desired_entry,
|
||||||
|
old_item_for_sql,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cache_entries_differ(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
|
||||||
|
"""Return ``True`` when two cache-entry dicts differ in any field.
|
||||||
|
|
||||||
|
Tag lists are compared order-insensitively; all other keys use
|
||||||
|
standard equality.
|
||||||
|
"""
|
||||||
|
a_tags = sorted(a.get("tags") or [])
|
||||||
|
b_tags = sorted(b.get("tags") or [])
|
||||||
|
if a_tags != b_tags:
|
||||||
|
return True
|
||||||
|
|
||||||
|
all_keys = set(a.keys()) | set(b.keys())
|
||||||
|
for key in all_keys:
|
||||||
|
if key == "tags":
|
||||||
|
continue
|
||||||
|
if a.get(key) != b.get(key):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def has_hash(self, sha256: str) -> bool:
|
def has_hash(self, sha256: str) -> bool:
|
||||||
"""Check if a model with given hash exists"""
|
"""Check if a model with given hash exists"""
|
||||||
return self._hash_index.has_hash(sha256.lower())
|
return self._hash_index.has_hash(sha256.lower())
|
||||||
@@ -1619,6 +1860,31 @@ class ModelScanner:
|
|||||||
return sorted_tags
|
return sorted_tags
|
||||||
return sorted_tags[:limit]
|
return sorted_tags[:limit]
|
||||||
|
|
||||||
|
async def search_tags(
|
||||||
|
self, query: str, limit: int = 50
|
||||||
|
) -> List[Dict[str, any]]:
|
||||||
|
"""Search tags by case-insensitive substring match, sorted by count.
|
||||||
|
|
||||||
|
If query is empty, behaves like get_top_tags (returns top ``limit``
|
||||||
|
tags). If limit is 0, all matching tags are returned.
|
||||||
|
"""
|
||||||
|
await self.get_cached_data()
|
||||||
|
|
||||||
|
normalized_query = (query or "").strip().lower()
|
||||||
|
if not normalized_query:
|
||||||
|
return await self.get_top_tags(limit if limit > 0 else 20)
|
||||||
|
|
||||||
|
matched = [
|
||||||
|
{"tag": tag, "count": count}
|
||||||
|
for tag, count in self._tags_count.items()
|
||||||
|
if normalized_query in tag.lower()
|
||||||
|
]
|
||||||
|
matched.sort(key=lambda x: x["count"], reverse=True)
|
||||||
|
|
||||||
|
if limit == 0:
|
||||||
|
return matched
|
||||||
|
return matched[:limit]
|
||||||
|
|
||||||
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
|
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||||
"""Get base models sorted by count. If limit is 0, return all."""
|
"""Get base models sorted by count. If limit is 0, return all."""
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
@@ -1734,6 +2000,8 @@ class ModelScanner:
|
|||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_require_path_in_library_roots(file_path, self, label="File path")
|
||||||
|
|
||||||
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, main_extension = os.path.splitext(base_name)
|
file_name, main_extension = os.path.splitext(base_name)
|
||||||
|
|||||||
@@ -587,6 +587,95 @@ class PersistentModelCache:
|
|||||||
placeholders = ", ".join(["?"] * len(self._MODEL_COLUMNS))
|
placeholders = ", ".join(["?"] * len(self._MODEL_COLUMNS))
|
||||||
return f"INSERT INTO models ({columns}) VALUES ({placeholders})"
|
return f"INSERT INTO models ({columns}) VALUES ({placeholders})"
|
||||||
|
|
||||||
|
def update_single_model(
|
||||||
|
self,
|
||||||
|
model_type: str,
|
||||||
|
new_item: Dict,
|
||||||
|
old_item: Optional[Dict] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Update a single model row in the persistent cache.
|
||||||
|
|
||||||
|
A lightweight alternative to :meth:`save_cache` that performs a targeted
|
||||||
|
DELETE + INSERT for the model row and computes incremental tag / hash-index
|
||||||
|
deltas from *old_item*. When *old_item* is omitted the previous tags and
|
||||||
|
hash are not cleaned up (callers should only omit it for brand-new entries).
|
||||||
|
|
||||||
|
All operations run inside a single transaction so readers see a consistent
|
||||||
|
view.
|
||||||
|
"""
|
||||||
|
if not self.is_enabled():
|
||||||
|
return
|
||||||
|
if not self._schema_initialized:
|
||||||
|
self._initialize_schema()
|
||||||
|
if not self._schema_initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
file_path: Optional[str] = new_item.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._db_lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
|
||||||
|
# --- model row (DELETE + INSERT = upsert) ---
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
|
||||||
|
(model_type, file_path),
|
||||||
|
)
|
||||||
|
row = self._prepare_model_row(model_type, new_item)
|
||||||
|
conn.execute(self._insert_model_sql(), row)
|
||||||
|
|
||||||
|
# --- tags ---
|
||||||
|
new_tags: set = set(new_item.get("tags") or [])
|
||||||
|
old_tags: set = set(old_item.get("tags") or []) if old_item else set()
|
||||||
|
tags_to_delete = old_tags - new_tags
|
||||||
|
tags_to_insert = new_tags - old_tags
|
||||||
|
|
||||||
|
if tags_to_delete:
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
|
||||||
|
[(model_type, file_path, t) for t in tags_to_delete],
|
||||||
|
)
|
||||||
|
if tags_to_insert:
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
|
||||||
|
[(model_type, file_path, t) for t in tags_to_insert],
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- hash_index ---
|
||||||
|
new_sha: Optional[str] = (new_item.get("sha256") or "").lower() or None
|
||||||
|
old_sha: Optional[str] = (
|
||||||
|
(old_item.get("sha256") or "").lower() or None
|
||||||
|
) if old_item else None
|
||||||
|
if new_sha != old_sha:
|
||||||
|
if old_sha:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
|
||||||
|
(model_type, old_sha, file_path),
|
||||||
|
)
|
||||||
|
if new_sha:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
|
||||||
|
(model_type, new_sha, file_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to update single model in persistent cache (%s): %s",
|
||||||
|
file_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
def _load_tags(self, conn: sqlite3.Connection, model_type: str) -> Dict[str, List[str]]:
|
def _load_tags(self, conn: sqlite3.Connection, model_type: str) -> Dict[str, List[str]]:
|
||||||
tag_rows = conn.execute(
|
tag_rows = conn.execute(
|
||||||
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
|
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Iterable, List, Dict, Optional
|
from typing import Iterable, List, Dict, Optional
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from operator import itemgetter
|
|
||||||
from natsort import natsorted
|
from natsort import natsorted
|
||||||
|
|
||||||
|
|
||||||
@@ -149,5 +148,10 @@ class RecipeCache:
|
|||||||
)
|
)
|
||||||
if not name_only:
|
if not name_only:
|
||||||
self.sorted_by_date = sorted(
|
self.sorted_by_date = sorted(
|
||||||
self.raw_data, key=itemgetter("created_date", "file_path"), reverse=True
|
self.raw_data,
|
||||||
|
key=lambda x: (
|
||||||
|
x.get("modified", x.get("created_date", 0)),
|
||||||
|
x.get("file_path", ""),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from .checkpoint_scanner import CheckpointScanner
|
|||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
from .recipes.errors import RecipeNotFoundError
|
from .recipes.errors import RecipeNotFoundError
|
||||||
from ..utils.civitai_utils import extract_civitai_image_id
|
from ..utils.civitai_utils import extract_civitai_image_id
|
||||||
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
|
from ..utils.utils import calculate_recipe_fingerprint
|
||||||
from natsort import natsorted
|
from natsort import natsorted
|
||||||
import sys
|
import sys
|
||||||
import re
|
import re
|
||||||
@@ -1020,13 +1020,16 @@ class RecipeScanner:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = self._fts_index.search(search, fields)
|
result = self._fts_index.search(search, fields)
|
||||||
# Return None if empty to trigger fuzzy fallback
|
# Return empty set for empty FTS results — do NOT fall back to
|
||||||
# Empty FTS results may indicate query syntax issues or need for fuzzy matching
|
# Python fuzzy matching, which freezes the server with 10k+ recipes.
|
||||||
|
# FTS5 prefix matching with unicode61 tokenizer correctly handles
|
||||||
|
# compound tokens (e.g. "illustrious" matches "path/illustrious/model").
|
||||||
|
# If FTS returns nothing, there are genuinely no matching recipes.
|
||||||
if not result:
|
if not result:
|
||||||
return None
|
return set()
|
||||||
return result
|
return result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("FTS search failed, falling back to fuzzy search: %s", exc)
|
logger.debug("FTS search failed, falling back to title-only search: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _update_fts_index_for_recipe(
|
def _update_fts_index_for_recipe(
|
||||||
@@ -2079,49 +2082,14 @@ class RecipeScanner:
|
|||||||
if str(item.get("id", "")) in fts_matching_ids
|
if str(item.get("id", "")) in fts_matching_ids
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
# Fallback to fuzzy_match (slower but always available)
|
# FTS index not yet built — return empty rather than
|
||||||
# Build the search predicate based on search options
|
# scanning 42k+ items in Python. The FTS background build
|
||||||
def matches_search(item):
|
# finishes in seconds; by the time a user navigates here
|
||||||
# Search in title if enabled
|
# and types a search, it is already available.
|
||||||
if search_options.get("title", True):
|
logger.debug(
|
||||||
if fuzzy_match(str(item.get("title", "")), search):
|
"FTS index not ready — search '%s' returning empty", search
|
||||||
return True
|
)
|
||||||
|
filtered_data = []
|
||||||
# Search in tags if enabled
|
|
||||||
if search_options.get("tags", True) and "tags" in item:
|
|
||||||
for tag in item["tags"]:
|
|
||||||
if fuzzy_match(tag, search):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Search in lora file names if enabled
|
|
||||||
if search_options.get("lora_name", True) and "loras" in item:
|
|
||||||
for lora in item["loras"]:
|
|
||||||
if fuzzy_match(str(lora.get("file_name", "")), search):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Search in lora model names if enabled
|
|
||||||
if search_options.get("lora_model", True) and "loras" in item:
|
|
||||||
for lora in item["loras"]:
|
|
||||||
if fuzzy_match(str(lora.get("modelName", "")), search):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Search in prompt and negative_prompt if enabled
|
|
||||||
if search_options.get("prompt", True) and "gen_params" in item:
|
|
||||||
gen_params = item["gen_params"]
|
|
||||||
if fuzzy_match(str(gen_params.get("prompt", "")), search):
|
|
||||||
return True
|
|
||||||
if fuzzy_match(
|
|
||||||
str(gen_params.get("negative_prompt", "")), search
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# No match found
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Filter the data using the search predicate
|
|
||||||
filtered_data = [
|
|
||||||
item for item in filtered_data if matches_search(item)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Apply additional filters
|
# Apply additional filters
|
||||||
if filters:
|
if filters:
|
||||||
|
|||||||
@@ -216,11 +216,12 @@ class RecipePersistenceService:
|
|||||||
"preview_nsfw_level",
|
"preview_nsfw_level",
|
||||||
"favorite",
|
"favorite",
|
||||||
"gen_params",
|
"gen_params",
|
||||||
|
"base_model",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not any(key in updates for key in allowed_fields):
|
if not any(key in updates for key in allowed_fields):
|
||||||
raise RecipeValidationError(
|
raise RecipeValidationError(
|
||||||
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params)"
|
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params or base_model)"
|
||||||
)
|
)
|
||||||
|
|
||||||
if "gen_params" in updates and not isinstance(updates["gen_params"], dict):
|
if "gen_params" in updates and not isinstance(updates["gen_params"], dict):
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"onboarding_completed": False,
|
"onboarding_completed": False,
|
||||||
"dismissed_banners": [],
|
"dismissed_banners": [],
|
||||||
"enable_metadata_archive_db": False,
|
"enable_metadata_archive_db": False,
|
||||||
|
"enable_civarchive_api": True,
|
||||||
|
"metadata_provider_order": "civitai_archive_sqlite",
|
||||||
"proxy_enabled": False,
|
"proxy_enabled": False,
|
||||||
"proxy_host": "",
|
"proxy_host": "",
|
||||||
"proxy_port": "",
|
"proxy_port": "",
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ class BulkMetadataRefreshUseCase:
|
|||||||
if sha256:
|
if sha256:
|
||||||
model["sha256"] = sha256
|
model["sha256"] = sha256
|
||||||
model["hash_status"] = "completed"
|
model["hash_status"] = "completed"
|
||||||
|
hash_status = "completed"
|
||||||
else:
|
else:
|
||||||
self._logger.error(f"Failed to calculate hash for {file_path}")
|
self._logger.error(f"Failed to calculate hash for {file_path}")
|
||||||
failures.append({"name": model.get("model_name", file_path or "Unknown"), "error": "Failed to calculate hash"})
|
failures.append({"name": model.get("model_name", file_path or "Unknown"), "error": "Failed to calculate hash"})
|
||||||
@@ -148,6 +149,16 @@ class BulkMetadataRefreshUseCase:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
await MetadataManager.hydrate_model_data(model)
|
await MetadataManager.hydrate_model_data(model)
|
||||||
|
|
||||||
|
# hydrate_model_data replaces model with .metadata.json content,
|
||||||
|
# which may lack sha256. Restore from cache and persist the fix.
|
||||||
|
if not model.get("sha256"):
|
||||||
|
model["sha256"] = sha256
|
||||||
|
model["hash_status"] = model.get("hash_status", hash_status)
|
||||||
|
data_to_save = model.copy()
|
||||||
|
data_to_save.pop("folder", None)
|
||||||
|
await MetadataManager.save_metadata(file_path, data_to_save)
|
||||||
|
|
||||||
result, error_msg = await self._metadata_sync.fetch_and_update_model(
|
result, error_msg = await self._metadata_sync.fetch_and_update_model(
|
||||||
sha256=model["sha256"],
|
sha256=model["sha256"],
|
||||||
file_path=model["file_path"],
|
file_path=model["file_path"],
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
|
|||||||
_WILDCARD_PATTERN = re.compile(r"__([\w\s.\-+/*\\]+?)__")
|
_WILDCARD_PATTERN = re.compile(r"__([\w\s.\-+/*\\]+?)__")
|
||||||
_OPTION_PATTERN = re.compile(r"{([^{}]*?)}")
|
_OPTION_PATTERN = re.compile(r"{([^{}]*?)}")
|
||||||
_TRIGGER_WORD_PATTERN = re.compile(r"^trigger_words\d+$")
|
_TRIGGER_WORD_PATTERN = re.compile(r"^trigger_words\d+$")
|
||||||
_WEIGHTED_OPTION_PATTERN = re.compile(r"^\s*([0-9.]+)::")
|
_WEIGHTED_OPTION_PATTERN = re.compile(r"^\s*-?\d+(\.\d+)?::")
|
||||||
_NUMERIC_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
|
_NUMERIC_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
|
||||||
|
|
||||||
|
|
||||||
@@ -390,7 +390,7 @@ class WildcardService:
|
|||||||
) -> str | None:
|
) -> str | None:
|
||||||
keyword = _normalize_wildcard_key(raw_key)
|
keyword = _normalize_wildcard_key(raw_key)
|
||||||
if keyword in wildcard_dict:
|
if keyword in wildcard_dict:
|
||||||
return rng.choice(wildcard_dict[keyword])
|
return self._pick_weighted_or_plain(wildcard_dict[keyword], rng)
|
||||||
|
|
||||||
if "*" in keyword:
|
if "*" in keyword:
|
||||||
regex_pattern = keyword.replace("*", ".*").replace("+", r"\+")
|
regex_pattern = keyword.replace("*", ".*").replace("+", r"\+")
|
||||||
@@ -400,7 +400,7 @@ class WildcardService:
|
|||||||
if compiled.match(key):
|
if compiled.match(key):
|
||||||
aggregated.extend(values)
|
aggregated.extend(values)
|
||||||
if aggregated:
|
if aggregated:
|
||||||
return rng.choice(aggregated)
|
return self._pick_weighted_or_plain(aggregated, rng)
|
||||||
|
|
||||||
if "/" not in keyword:
|
if "/" not in keyword:
|
||||||
fallback_keyword = _normalize_wildcard_key(f"*/{keyword}")
|
fallback_keyword = _normalize_wildcard_key(f"*/{keyword}")
|
||||||
@@ -409,6 +409,39 @@ class WildcardService:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _pick_weighted_or_plain(
|
||||||
|
self, values: list[str], rng: random.Random
|
||||||
|
) -> str:
|
||||||
|
"""Pick a value from the list, respecting N::weight prefix if present.
|
||||||
|
|
||||||
|
When any value in the list uses the ``N::value`` weighted syntax with a
|
||||||
|
weight different from 1, the pick uses weighted random selection. When
|
||||||
|
no such weighting is present, a plain ``rng.choice`` is used (preserving
|
||||||
|
backward compatibility for unweighted wildcard files).
|
||||||
|
|
||||||
|
In either case the ``N::`` prefix is always stripped from the returned
|
||||||
|
value, matching the behaviour of ``{...}`` option groups.
|
||||||
|
"""
|
||||||
|
# Fast path: skip weighting logic entirely when no :: syntax exists
|
||||||
|
if not any("::" in v for v in values):
|
||||||
|
return rng.choice(values)
|
||||||
|
|
||||||
|
weighted_options: list[tuple[float, str]] = []
|
||||||
|
for value in values:
|
||||||
|
weight = 1.0
|
||||||
|
parts = value.split("::", 1)
|
||||||
|
if len(parts) == 2 and _is_numeric_string(parts[0].strip()):
|
||||||
|
weight = float(parts[0].strip())
|
||||||
|
weighted_options.append((weight, value))
|
||||||
|
|
||||||
|
any_weighted = any(w != 1.0 for w, _ in weighted_options)
|
||||||
|
if any_weighted:
|
||||||
|
picked = self._weighted_choice(weighted_options, rng)
|
||||||
|
else:
|
||||||
|
picked = rng.choice(values)
|
||||||
|
|
||||||
|
return self._strip_weight_prefix(picked)
|
||||||
|
|
||||||
|
|
||||||
def is_trigger_words_input(name: str) -> bool:
|
def is_trigger_words_input(name: str) -> bool:
|
||||||
return bool(_TRIGGER_WORD_PATTERN.match(name))
|
return bool(_TRIGGER_WORD_PATTERN.match(name))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ NODE_TYPES = {
|
|||||||
"Lora Loader (LoraManager)": 1,
|
"Lora Loader (LoraManager)": 1,
|
||||||
"Lora Stacker (LoraManager)": 2,
|
"Lora Stacker (LoraManager)": 2,
|
||||||
"WanVideo Lora Select (LoraManager)": 3,
|
"WanVideo Lora Select (LoraManager)": 3,
|
||||||
|
"Create Hook LoRA (LoraManager)": 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Default ComfyUI node color when bgcolor is null
|
# Default ComfyUI node color when bgcolor is null
|
||||||
|
|||||||
@@ -14,11 +14,16 @@ from ..services.service_registry import ServiceRegistry
|
|||||||
from ..utils.example_images_paths import (
|
from ..utils.example_images_paths import (
|
||||||
ExampleImagePathResolver,
|
ExampleImagePathResolver,
|
||||||
ensure_library_root_exists,
|
ensure_library_root_exists,
|
||||||
|
get_example_images_root,
|
||||||
|
is_hash_folder,
|
||||||
uses_library_scoped_folders,
|
uses_library_scoped_folders,
|
||||||
)
|
)
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from .example_images_processor import ExampleImagesProcessor
|
from .example_images_processor import ExampleImagesProcessor
|
||||||
from .example_images_metadata import MetadataUpdater
|
from .example_images_metadata import (
|
||||||
|
MetadataUpdater,
|
||||||
|
update_cache_from_metadata,
|
||||||
|
)
|
||||||
from ..services.downloader import get_downloader
|
from ..services.downloader import get_downloader
|
||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
@@ -87,6 +92,13 @@ class _DownloadProgress(dict):
|
|||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
# When fewer candidates than this remain in check_pending_models, probe each
|
||||||
|
# model folder directly (preserving legacy-folder migration semantics). Above
|
||||||
|
# it, build a folder index with a single directory scan so libraries with
|
||||||
|
# 100k+ models do not pay one syscall per candidate.
|
||||||
|
_BULK_LOOKUP_THRESHOLD = 1000
|
||||||
|
|
||||||
|
|
||||||
def _model_directory_has_files(path: str) -> bool:
|
def _model_directory_has_files(path: str) -> bool:
|
||||||
"""Return True when the provided directory exists and contains entries."""
|
"""Return True when the provided directory exists and contains entries."""
|
||||||
|
|
||||||
@@ -103,6 +115,36 @@ def _model_directory_has_files(path: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _build_example_folder_index(output_dir: str) -> dict[str, bool]:
|
||||||
|
"""Build a ``{hash: has_files}`` index for a library's example-image folders.
|
||||||
|
|
||||||
|
A single directory scan over the library root replaces ``O(candidates)``
|
||||||
|
per-folder ``os.scandir`` calls, which is required for libraries with
|
||||||
|
100k+ models. Each hash folder is classified by whether it contains any
|
||||||
|
entries, matching the semantics of ``_model_directory_has_files``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
index: dict[str, bool] = {}
|
||||||
|
if not output_dir or not os.path.isdir(output_dir):
|
||||||
|
return index
|
||||||
|
|
||||||
|
try:
|
||||||
|
with os.scandir(output_dir) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
name = entry.name
|
||||||
|
if not entry.is_dir() or not is_hash_folder(name):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with os.scandir(entry.path) as subentries:
|
||||||
|
index[name.lower()] = any(subentries)
|
||||||
|
except OSError:
|
||||||
|
index[name.lower()] = False
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
class DownloadManager:
|
class DownloadManager:
|
||||||
"""Manages downloading example images for models."""
|
"""Manages downloading example images for models."""
|
||||||
|
|
||||||
@@ -410,14 +452,49 @@ class DownloadManager:
|
|||||||
# Calculate pending count: check which models actually need processing.
|
# Calculate pending count: check which models actually need processing.
|
||||||
# A model is pending if it has a hash, is not already processed or known-failed,
|
# A model is pending if it has a hash, is not already processed or known-failed,
|
||||||
# and its folder doesn't exist or is empty.
|
# and its folder doesn't exist or is empty.
|
||||||
pending_hashes = set()
|
candidate_hashes = [
|
||||||
for model_hash, model_name in all_models_with_hash:
|
model_hash
|
||||||
if model_hash not in processed_models and model_hash not in failed_models:
|
for model_hash, _ in all_models_with_hash
|
||||||
|
if model_hash not in processed_models
|
||||||
|
and model_hash not in failed_models
|
||||||
|
]
|
||||||
|
|
||||||
|
pending_hashes: set[str] = set()
|
||||||
|
# For small candidate counts the existing per-folder check is fine
|
||||||
|
# and handles legacy folder migration.
|
||||||
|
# For large libraries, scan the library root once and do set lookups.
|
||||||
|
if len(candidate_hashes) <= _BULK_LOOKUP_THRESHOLD or not output_dir:
|
||||||
|
for model_hash in candidate_hashes:
|
||||||
model_dir = ExampleImagePathResolver.get_model_folder(
|
model_dir = ExampleImagePathResolver.get_model_folder(
|
||||||
model_hash, active_library
|
model_hash, active_library
|
||||||
)
|
)
|
||||||
if not _model_directory_has_files(model_dir):
|
if not _model_directory_has_files(model_dir):
|
||||||
pending_hashes.add(model_hash)
|
pending_hashes.add(model_hash)
|
||||||
|
else:
|
||||||
|
folder_index = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, _build_example_folder_index, output_dir
|
||||||
|
)
|
||||||
|
# In multi-library mode, folders that have not been consolidated
|
||||||
|
# into the library root yet (startup migration skipped, failed
|
||||||
|
# move, or created at the legacy path afterwards) still live at
|
||||||
|
# the legacy root/<hash> location. Only scan that root when at
|
||||||
|
# least one candidate is missing from the library-root index, so
|
||||||
|
# the fully-consolidated case does not pay an extra directory
|
||||||
|
# pass on every call.
|
||||||
|
if uses_library_scoped_folders() and any(
|
||||||
|
not folder_index.get(model_hash, False)
|
||||||
|
for model_hash in candidate_hashes
|
||||||
|
):
|
||||||
|
legacy_root = get_example_images_root()
|
||||||
|
if legacy_root and legacy_root != output_dir:
|
||||||
|
legacy_index = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, _build_example_folder_index, legacy_root
|
||||||
|
)
|
||||||
|
for hash_key, has_files in legacy_index.items():
|
||||||
|
folder_index.setdefault(hash_key, has_files)
|
||||||
|
for model_hash in candidate_hashes:
|
||||||
|
if not folder_index.get(model_hash, False):
|
||||||
|
pending_hashes.add(model_hash)
|
||||||
|
|
||||||
pending_count = len(pending_hashes)
|
pending_count = len(pending_hashes)
|
||||||
|
|
||||||
@@ -1343,8 +1420,8 @@ class DownloadManager:
|
|||||||
await MetadataManager.save_metadata(file_path, model_copy)
|
await MetadataManager.save_metadata(file_path, model_copy)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await scanner.update_single_model_cache(
|
await update_cache_from_metadata(
|
||||||
file_path, file_path, model_data
|
scanner, file_path, model_copy
|
||||||
)
|
)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -28,6 +29,31 @@ if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
|||||||
from ..services.settings_manager import SettingsManager
|
from ..services.settings_manager import SettingsManager
|
||||||
|
|
||||||
|
|
||||||
|
async def update_cache_from_metadata(
|
||||||
|
scanner: Any, file_path: str, metadata: Dict[str, Any]
|
||||||
|
) -> bool:
|
||||||
|
"""Update the scanner cache from a metadata dict using the in-place sync path.
|
||||||
|
|
||||||
|
``sync_cache_from_metadata`` patches the existing cache entry incrementally
|
||||||
|
(tag/hash/version indexes, targeted single-row SQL update) and only resorts
|
||||||
|
when a sort-key field changed. This avoids the ``O(n)`` full-list resort and
|
||||||
|
full cache rewrite that ``update_single_model_cache`` performs on every call,
|
||||||
|
which is critical for libraries with 100k+ models.
|
||||||
|
|
||||||
|
Falls back to the legacy full update when the scanner does not expose an
|
||||||
|
async ``sync_cache_from_metadata`` method.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` if the cache entry was updated, ``False`` otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sync_method = getattr(scanner, "sync_cache_from_metadata", None)
|
||||||
|
if inspect.iscoroutinefunction(sync_method):
|
||||||
|
return await sync_method(file_path, metadata)
|
||||||
|
|
||||||
|
return await scanner.update_single_model_cache(file_path, file_path, metadata)
|
||||||
|
|
||||||
|
|
||||||
def _build_metadata_sync_service(settings_manager: "SettingsManager") -> MetadataSyncService:
|
def _build_metadata_sync_service(settings_manager: "SettingsManager") -> MetadataSyncService:
|
||||||
"""Construct a metadata sync service bound to the provided settings."""
|
"""Construct a metadata sync service bound to the provided settings."""
|
||||||
|
|
||||||
@@ -103,7 +129,7 @@ class MetadataUpdater:
|
|||||||
progress['refreshed_models'].add(model_hash)
|
progress['refreshed_models'].add(model_hash)
|
||||||
|
|
||||||
async def update_cache_func(old_path, new_path, metadata):
|
async def update_cache_func(old_path, new_path, metadata):
|
||||||
return await scanner.update_single_model_cache(old_path, new_path, metadata)
|
return await update_cache_from_metadata(scanner, new_path, metadata)
|
||||||
|
|
||||||
await MetadataManager.hydrate_model_data(model_data)
|
await MetadataManager.hydrate_model_data(model_data)
|
||||||
success, error = await _get_metadata_sync_service().fetch_and_update_model(
|
success, error = await _get_metadata_sync_service().fetch_and_update_model(
|
||||||
@@ -234,6 +260,7 @@ class MetadataUpdater:
|
|||||||
|
|
||||||
# Save metadata to .metadata.json file
|
# Save metadata to .metadata.json file
|
||||||
file_path = model.get('file_path')
|
file_path = model.get('file_path')
|
||||||
|
model_copy: Optional[Dict[str, Any]] = None
|
||||||
try:
|
try:
|
||||||
model_copy = model.copy()
|
model_copy = model.copy()
|
||||||
model_copy.pop('folder', None)
|
model_copy.pop('folder', None)
|
||||||
@@ -242,13 +269,17 @@ class MetadataUpdater:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save metadata for {model.get('model_name')}: {str(e)}")
|
logger.error(f"Failed to save metadata for {model.get('model_name')}: {str(e)}")
|
||||||
|
|
||||||
# Save updated metadata to scanner cache
|
# Save updated metadata to scanner cache. sync_cache_from_metadata
|
||||||
success = await scanner.update_single_model_cache(file_path, file_path, model)
|
# returns False both for "already in sync" and for actual failures,
|
||||||
if success:
|
# so the cache sync result is deliberately not treated as an error;
|
||||||
|
# the return value reflects whether the metadata was persisted.
|
||||||
|
if file_path and model_copy is not None:
|
||||||
|
await update_cache_from_metadata(scanner, file_path, model_copy)
|
||||||
logger.info(f"Successfully updated metadata for {model.get('model_name')} with {len(images)} local examples")
|
logger.info(f"Successfully updated metadata for {model.get('model_name')} with {len(images)} local examples")
|
||||||
return True
|
return True
|
||||||
else:
|
|
||||||
logger.warning(f"Failed to update metadata for {model.get('model_name')}")
|
logger.warning(f"Failed to update metadata for {model.get('model_name')}")
|
||||||
|
return False
|
||||||
|
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -336,6 +367,7 @@ class MetadataUpdater:
|
|||||||
|
|
||||||
# Save metadata to .metadata.json file
|
# Save metadata to .metadata.json file
|
||||||
file_path = model_data.get('file_path')
|
file_path = model_data.get('file_path')
|
||||||
|
model_copy: Optional[Dict[str, Any]] = None
|
||||||
if file_path:
|
if file_path:
|
||||||
try:
|
try:
|
||||||
model_copy = model_data.copy()
|
model_copy = model_data.copy()
|
||||||
@@ -346,8 +378,8 @@ class MetadataUpdater:
|
|||||||
logger.error(f"Failed to save metadata: {str(e)}")
|
logger.error(f"Failed to save metadata: {str(e)}")
|
||||||
|
|
||||||
# Save updated metadata to scanner cache
|
# Save updated metadata to scanner cache
|
||||||
if file_path:
|
if file_path and model_copy is not None:
|
||||||
await scanner.update_single_model_cache(file_path, file_path, model_data)
|
await update_cache_from_metadata(scanner, file_path, model_copy)
|
||||||
|
|
||||||
# Get regular images array (might be None)
|
# Get regular images array (might be None)
|
||||||
regular_images = civitai_data.get('images', [])
|
regular_images = civitai_data.get('images', [])
|
||||||
@@ -475,13 +507,19 @@ class MetadataUpdater:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
model_folder = get_model_folder(model_hash)
|
model_folder = get_model_folder(model_hash)
|
||||||
if not model_folder:
|
if not model_folder or not os.path.isdir(model_folder):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
civitai = getattr(metadata, "civitai", None)
|
civitai = getattr(metadata, "civitai", None)
|
||||||
if not isinstance(civitai, dict):
|
if not isinstance(civitai, dict):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Read the directory listing once so every image entry reuses it.
|
||||||
|
try:
|
||||||
|
dir_entries = os.listdir(model_folder)
|
||||||
|
except OSError:
|
||||||
|
dir_entries = []
|
||||||
|
|
||||||
has_changes = False
|
has_changes = False
|
||||||
|
|
||||||
custom_images = civitai.get("customImages")
|
custom_images = civitai.get("customImages")
|
||||||
@@ -493,22 +531,13 @@ class MetadataUpdater:
|
|||||||
if not img_id:
|
if not img_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not os.path.isdir(model_folder):
|
|
||||||
stale.append(idx)
|
|
||||||
else:
|
|
||||||
found = False
|
|
||||||
try:
|
|
||||||
prefix = f"custom_{img_id}"
|
prefix = f"custom_{img_id}"
|
||||||
for fname in os.listdir(model_folder):
|
found = any(
|
||||||
if fname.startswith(prefix) and os.path.isfile(
|
f.startswith(prefix) and os.path.isfile(
|
||||||
os.path.join(model_folder, fname)
|
os.path.join(model_folder, f)
|
||||||
):
|
)
|
||||||
found = True
|
for f in dir_entries
|
||||||
break
|
)
|
||||||
except OSError:
|
|
||||||
stale.append(idx)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not found:
|
if not found:
|
||||||
stale.append(idx)
|
stale.append(idx)
|
||||||
|
|
||||||
@@ -532,21 +561,8 @@ class MetadataUpdater:
|
|||||||
# is gone.
|
# is gone.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not os.path.isdir(model_folder):
|
|
||||||
stale.append(idx)
|
|
||||||
else:
|
|
||||||
found = False
|
|
||||||
try:
|
|
||||||
prefix = f"image_{idx}."
|
prefix = f"image_{idx}."
|
||||||
for fname in os.listdir(model_folder):
|
if not any(f.startswith(prefix) for f in dir_entries):
|
||||||
if fname.startswith(prefix):
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
except OSError:
|
|
||||||
stale.append(idx)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not found:
|
|
||||||
stale.append(idx)
|
stale.append(idx)
|
||||||
|
|
||||||
if stale:
|
if stale:
|
||||||
|
|||||||
@@ -3,11 +3,19 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
from ..utils.example_images_paths import iter_library_roots
|
from ..utils.example_images_paths import (
|
||||||
|
get_example_images_root,
|
||||||
|
is_hash_folder,
|
||||||
|
iter_library_roots,
|
||||||
|
uses_library_scoped_folders,
|
||||||
|
_library_folder_has_only_hash_dirs,
|
||||||
|
)
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from ..utils.example_images_processor import ExampleImagesProcessor
|
from ..utils.example_images_processor import ExampleImagesProcessor
|
||||||
|
from ..utils.example_images_metadata import update_cache_from_metadata
|
||||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -36,6 +44,90 @@ settings = _SettingsProxy()
|
|||||||
class ExampleImagesMigration:
|
class ExampleImagesMigration:
|
||||||
"""Handles migrations for example images naming conventions"""
|
"""Handles migrations for example images naming conventions"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _consolidate_library_folders():
|
||||||
|
"""Move hash folders from library-named subdirectories back to root.
|
||||||
|
|
||||||
|
When a user switches from multi-library mode back to single-library
|
||||||
|
mode, example images previously stored under e.g.
|
||||||
|
``<root>/default/<hash>/`` need to be moved back to
|
||||||
|
``<root>/<hash>/``. Running this once at startup removes the need
|
||||||
|
for ``get_model_folder()`` to perform directory scans on every
|
||||||
|
request.
|
||||||
|
"""
|
||||||
|
if uses_library_scoped_folders():
|
||||||
|
return
|
||||||
|
|
||||||
|
root = get_example_images_root()
|
||||||
|
if not root or not os.path.isdir(root):
|
||||||
|
return
|
||||||
|
|
||||||
|
moved: list[str] = []
|
||||||
|
cleaned: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for entry in os.listdir(root):
|
||||||
|
# Fast regex checks first — no filesystem I/O.
|
||||||
|
if is_hash_folder(entry) or entry == "_deleted":
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry_path = os.path.join(root, entry)
|
||||||
|
if not os.path.isdir(entry_path):
|
||||||
|
continue
|
||||||
|
if not _library_folder_has_only_hash_dirs(entry_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
for hash_entry in os.listdir(entry_path):
|
||||||
|
hash_path = os.path.join(entry_path, hash_entry)
|
||||||
|
if not os.path.isdir(hash_path) or not is_hash_folder(hash_entry):
|
||||||
|
continue
|
||||||
|
target = os.path.join(root, hash_entry)
|
||||||
|
if not os.path.exists(target):
|
||||||
|
try:
|
||||||
|
shutil.move(hash_path, target)
|
||||||
|
moved.append(hash_entry)
|
||||||
|
except (OSError, shutil.Error) as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to move '%s' → '%s': %s",
|
||||||
|
hash_path, target, exc,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to list library subdirectory '%s': %s",
|
||||||
|
entry_path, exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
remaining = os.listdir(entry_path)
|
||||||
|
except OSError:
|
||||||
|
remaining = []
|
||||||
|
if not remaining:
|
||||||
|
try:
|
||||||
|
os.rmdir(entry_path)
|
||||||
|
cleaned.append(entry)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Could not remove empty library dir '%s': %s",
|
||||||
|
entry_path, exc,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to list example images root during consolidation: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if moved:
|
||||||
|
logger.info(
|
||||||
|
"Consolidated %d example image folder(s) to root",
|
||||||
|
len(moved),
|
||||||
|
)
|
||||||
|
if cleaned:
|
||||||
|
logger.info(
|
||||||
|
"Removed %d empty library directories",
|
||||||
|
len(cleaned),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def check_and_run_migrations():
|
async def check_and_run_migrations():
|
||||||
"""Check if migrations are needed and run them in background"""
|
"""Check if migrations are needed and run them in background"""
|
||||||
@@ -44,6 +136,10 @@ class ExampleImagesMigration:
|
|||||||
logger.debug("No example images path configured or path doesn't exist, skipping migrations")
|
logger.debug("No example images path configured or path doesn't exist, skipping migrations")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Run library-to-root consolidation once at startup so the hot
|
||||||
|
# path (get_model_folder) stays a pure-path computation.
|
||||||
|
ExampleImagesMigration._consolidate_library_folders()
|
||||||
|
|
||||||
for library_name, library_path in iter_library_roots():
|
for library_name, library_path in iter_library_roots():
|
||||||
if not library_path or not os.path.exists(library_path):
|
if not library_path or not os.path.exists(library_path):
|
||||||
continue
|
continue
|
||||||
@@ -326,7 +422,7 @@ class ExampleImagesMigration:
|
|||||||
await MetadataManager.save_metadata(file_path, model_copy)
|
await MetadataManager.save_metadata(file_path, model_copy)
|
||||||
|
|
||||||
# Update scanner cache
|
# Update scanner cache
|
||||||
await scanner.update_single_model_cache(file_path, file_path, model_metadata)
|
await update_cache_from_metadata(scanner, file_path, model_copy)
|
||||||
|
|
||||||
updated_models += 1
|
updated_models += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -83,7 +83,12 @@ def ensure_library_root_exists(library_name: Optional[str] = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str:
|
def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str:
|
||||||
"""Return the folder path for a model's example images."""
|
"""Return the folder path for a model's example images.
|
||||||
|
|
||||||
|
Multi-library ↔ single-library consolidation is handled once at startup by
|
||||||
|
``ExampleImagesMigration._consolidate_library_folders`` — this function is a
|
||||||
|
pure path computation on the hot path (no directory scans).
|
||||||
|
"""
|
||||||
|
|
||||||
if not model_hash:
|
if not model_hash:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
|||||||
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 ..utils.example_images_paths import get_model_folder, get_model_relative_path
|
from ..utils.example_images_paths import get_model_folder, get_model_relative_path
|
||||||
from .example_images_metadata import MetadataUpdater
|
from .example_images_metadata import MetadataUpdater, update_cache_from_metadata
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -644,7 +644,7 @@ class ExampleImagesProcessor:
|
|||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
# Update cache
|
# Update cache
|
||||||
await scanner.update_single_model_cache(file_path, file_path, model_data)
|
await update_cache_from_metadata(scanner, file_path, model_data)
|
||||||
|
|
||||||
# Get regular images array (might be None)
|
# Get regular images array (might be None)
|
||||||
regular_images = civitai_data.get('images', [])
|
regular_images = civitai_data.get('images', [])
|
||||||
@@ -759,7 +759,7 @@ class ExampleImagesProcessor:
|
|||||||
model_copy = model_data.copy()
|
model_copy = model_data.copy()
|
||||||
model_copy.pop('folder', None)
|
model_copy.pop('folder', None)
|
||||||
await MetadataManager.save_metadata(file_path, model_copy)
|
await MetadataManager.save_metadata(file_path, model_copy)
|
||||||
await scanner.update_single_model_cache(file_path, file_path, model_data)
|
await update_cache_from_metadata(scanner, file_path, model_copy)
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': True,
|
'success': True,
|
||||||
|
|||||||
+54
-1
@@ -1,7 +1,7 @@
|
|||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Dict
|
from typing import Any, Dict, List, Optional
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
@@ -294,6 +294,53 @@ def _format_model_name_for_comfyui(file_path: str, model_roots: list) -> str:
|
|||||||
return os.path.basename(file_path)
|
return os.path.basename(file_path)
|
||||||
|
|
||||||
|
|
||||||
|
def model_patcher_to_name(model_patcher: Any) -> Optional[str]:
|
||||||
|
"""Extract a ComfyUI-style model name from a MODEL (ModelPatcher) object.
|
||||||
|
|
||||||
|
Core ComfyUI loaders record the absolute weight file path on the patcher's
|
||||||
|
``cached_patcher_init`` attribute:
|
||||||
|
- load_checkpoint_guess_config -> (fn, (ckpt_path, ...), index)
|
||||||
|
- load_diffusion_model -> (fn, (unet_path, model_options))
|
||||||
|
Patcher clones (LoRA loaders, model merges, ...) preserve the attribute,
|
||||||
|
so the name is recoverable anywhere downstream of a core loader — including
|
||||||
|
from LoRA Manager's own loaders (CheckpointLoaderLM / UNETLoaderLM), which
|
||||||
|
call the same core load functions.
|
||||||
|
|
||||||
|
The absolute path is converted to the ComfyUI-style relative name used by
|
||||||
|
the metadata pipeline (covering standard ComfyUI roots and LoRA Manager
|
||||||
|
extra folder paths).
|
||||||
|
|
||||||
|
Returns None when the path cannot be recovered (e.g. third-party loaders
|
||||||
|
that never set ``cached_patcher_init``).
|
||||||
|
"""
|
||||||
|
init = getattr(model_patcher, "cached_patcher_init", None)
|
||||||
|
if not isinstance(init, (tuple, list)) or len(init) < 2:
|
||||||
|
return None
|
||||||
|
args = init[1]
|
||||||
|
abs_path = args[0] if args else None
|
||||||
|
if not isinstance(abs_path, str) or not abs_path:
|
||||||
|
return None
|
||||||
|
return _abs_model_path_to_name(abs_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _abs_model_path_to_name(abs_path: str) -> str:
|
||||||
|
"""Convert an absolute model path to a ComfyUI-style relative name.
|
||||||
|
|
||||||
|
Tries standard ComfyUI model roots plus LoRA Manager extra folder paths;
|
||||||
|
falls back to the bare filename.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
roots: List[str] = list(config.base_models_roots or [])
|
||||||
|
roots.extend(config.extra_checkpoints_roots or [])
|
||||||
|
roots.extend(config.extra_unet_roots or [])
|
||||||
|
formatted = _format_model_name_for_comfyui(abs_path, roots)
|
||||||
|
if formatted:
|
||||||
|
return formatted
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return os.path.basename(abs_path)
|
||||||
|
|
||||||
|
|
||||||
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
|
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if text matches pattern using fuzzy matching.
|
Check if text matches pattern using fuzzy matching.
|
||||||
@@ -488,6 +535,12 @@ def calculate_relative_path_for_model(
|
|||||||
if model_type == "embedding":
|
if model_type == "embedding":
|
||||||
formatted_path = formatted_path.replace(" ", "_")
|
formatted_path = formatted_path.replace(" ", "_")
|
||||||
|
|
||||||
|
# Sanitize the resolved path to prevent path traversal
|
||||||
|
formatted_path = formatted_path.lstrip("/")
|
||||||
|
while "//" in formatted_path:
|
||||||
|
formatted_path = formatted_path.replace("//", "/")
|
||||||
|
formatted_path = formatted_path.rstrip("/")
|
||||||
|
|
||||||
return formatted_path
|
return formatted_path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.1.7"
|
version = "1.2.0"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
# Ensure the script's directory is on sys.path so that py.* imports resolve
|
||||||
|
# regardless of the current working directory (e.g. when launched via
|
||||||
|
# ComfyUI's python_embeded from the ComfyUI root directory).
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
from py.middleware.cache_middleware import cache_control
|
from py.middleware.cache_middleware import cache_control
|
||||||
from py.middleware.error_middleware import api_json_error
|
from py.middleware.error_middleware import api_json_error
|
||||||
from py.utils.settings_paths import ensure_settings_file
|
from py.utils.settings_paths import ensure_settings_file
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ body.modal-open {
|
|||||||
.support-section,
|
.support-section,
|
||||||
.changelog-section,
|
.changelog-section,
|
||||||
.update-info,
|
.update-info,
|
||||||
|
.update-channels,
|
||||||
.info-item,
|
.info-item,
|
||||||
.path-preview {
|
.path-preview {
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
|
|||||||
@@ -93,15 +93,13 @@
|
|||||||
.update-content {
|
.update-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-info {
|
.update-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-info .version-info {
|
.update-info .version-info {
|
||||||
@@ -175,7 +173,6 @@
|
|||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
margin: var(--space-2) 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .update-progress {
|
[data-theme="dark"] .update-progress {
|
||||||
@@ -233,11 +230,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Changelog section */
|
/* Changelog section */
|
||||||
.changelog-section {
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.changelog-section h3 {
|
.changelog-section h3 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
@@ -349,6 +341,131 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Channel Toggle */
|
||||||
|
.update-channels {
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-label {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-btn:hover {
|
||||||
|
color: var(--text-primary, #ddd);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-btn.active {
|
||||||
|
background: var(--lora-accent, #4285F4);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-btn.active i {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-btn i {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Channel Switch Confirmation Overlay */
|
||||||
|
.channel-switch-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-dialog {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 28px 32px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-dialog h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: var(--text-primary, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-dialog p {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-secondary, #aaa);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-cancel {
|
||||||
|
padding: 8px 18px;
|
||||||
|
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #aaa);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-cancel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-confirm {
|
||||||
|
padding: 8px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--lora-accent, #4285F4);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-switch-confirm:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
/* Update preferences section */
|
/* Update preferences section */
|
||||||
.update-preferences {
|
.update-preferences {
|
||||||
border-top: 1px solid var(--lora-border);
|
border-top: 1px solid var(--lora-border);
|
||||||
|
|||||||
@@ -274,6 +274,11 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline extra tags (selected but not in top-20/appended after API results) */
|
||||||
|
.filter-tag.extra-tag {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ensure solid border and full opacity when active or excluded */
|
/* Ensure solid border and full opacity when active or excluded */
|
||||||
.filter-tag.special-tag.active,
|
.filter-tag.special-tag.active,
|
||||||
.filter-tag.special-tag.exclude {
|
.filter-tag.special-tag.exclude {
|
||||||
|
|||||||
@@ -49,10 +49,6 @@ export const MODEL_CONFIG = {
|
|||||||
* @returns {Object} Object containing all API endpoints for the model type
|
* @returns {Object} Object containing all API endpoints for the model type
|
||||||
*/
|
*/
|
||||||
export function getApiEndpoints(modelType) {
|
export function getApiEndpoints(modelType) {
|
||||||
if (!Object.values(MODEL_TYPES).includes(modelType)) {
|
|
||||||
throw new Error(`Invalid model type: ${modelType}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Base CRUD operations
|
// Base CRUD operations
|
||||||
list: `/api/lm/${modelType}/list`,
|
list: `/api/lm/${modelType}/list`,
|
||||||
@@ -93,6 +89,7 @@ export function getApiEndpoints(modelType) {
|
|||||||
// Query operations
|
// Query operations
|
||||||
scan: `/api/lm/${modelType}/scan`,
|
scan: `/api/lm/${modelType}/scan`,
|
||||||
topTags: `/api/lm/${modelType}/top-tags`,
|
topTags: `/api/lm/${modelType}/top-tags`,
|
||||||
|
searchTags: `/api/lm/${modelType}/search-tags`,
|
||||||
baseModels: `/api/lm/${modelType}/base-models`,
|
baseModels: `/api/lm/${modelType}/base-models`,
|
||||||
roots: `/api/lm/${modelType}/roots`,
|
roots: `/api/lm/${modelType}/roots`,
|
||||||
folders: `/api/lm/${modelType}/folders`,
|
folders: `/api/lm/${modelType}/folders`,
|
||||||
|
|||||||
@@ -152,7 +152,9 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
sendLoraToWorkflow(replaceMode) {
|
sendLoraToWorkflow(replaceMode) {
|
||||||
const card = this.currentCard;
|
const card = this.currentCard;
|
||||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||||
const loraSyntax = buildLoraSyntax(card.dataset.file_name, usageTips);
|
const folder = card.dataset.folder || '';
|
||||||
|
const loraName = folder ? `${folder}/${card.dataset.file_name}` : card.dataset.file_name;
|
||||||
|
const loraSyntax = buildLoraSyntax(loraName, usageTips);
|
||||||
|
|
||||||
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,8 +260,9 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
strength: lora.strength || 1.0,
|
strength: lora.strength || 1.0,
|
||||||
|
|
||||||
// Model identifiers
|
// Model identifiers
|
||||||
|
modelId: lora.modelId || lora.model_id || civitaiInfo.modelId,
|
||||||
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
|
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
|
||||||
modelVersionId: civitaiInfo.id || lora.modelVersionId,
|
id: civitaiInfo.id || lora.modelVersionId,
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
thumbnailUrl: civitaiInfo.images?.[0]?.url || '',
|
thumbnailUrl: civitaiInfo.images?.[0]?.url || '',
|
||||||
|
|||||||
@@ -1421,6 +1421,7 @@ class RecipeModal {
|
|||||||
strength: lora.strength || 1.0,
|
strength: lora.strength || 1.0,
|
||||||
|
|
||||||
// Model identifiers
|
// Model identifiers
|
||||||
|
modelId: lora.modelId || lora.model_id || civitaiInfo.modelId,
|
||||||
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
|
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
|
||||||
id: civitaiInfo.id || lora.modelVersionId,
|
id: civitaiInfo.id || lora.modelVersionId,
|
||||||
|
|
||||||
|
|||||||
@@ -108,10 +108,20 @@ export class PageControls {
|
|||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
initSortDropdown(sortSelect);
|
initSortDropdown(sortSelect);
|
||||||
sortSelect.value = this.pageState.sortBy;
|
this.applySortToSelect(this.pageState.sortBy);
|
||||||
sortSelect.addEventListener('change', async (e) => {
|
sortSelect.addEventListener('change', async (e) => {
|
||||||
this.pageState.sortBy = e.target.value;
|
let value = e.target.value;
|
||||||
this.saveSortPreference(e.target.value);
|
if (value.startsWith('random')) {
|
||||||
|
// Every pick of Random reshuffles the list: generate a
|
||||||
|
// fresh seed so the backend keeps a stable order across
|
||||||
|
// paginated requests.
|
||||||
|
value = this._randomizeSortValue();
|
||||||
|
}
|
||||||
|
this.pageState.sortBy = value;
|
||||||
|
this.saveSortPreference(value);
|
||||||
|
// Reset the seeded Random option when switching away from
|
||||||
|
// Random, or re-apply the fresh seed when picking it again.
|
||||||
|
this.applySortToSelect(value);
|
||||||
await this.resetAndReload();
|
await this.resetAndReload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -312,6 +322,44 @@ export class PageControls {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a sort value to the native sort <select>, keeping the Random
|
||||||
|
* option's value in sync when the persisted value carries a seed
|
||||||
|
* (e.g. "random:abc123"). Must be used instead of assigning
|
||||||
|
* sortSelect.value directly whenever the value may be a seeded random
|
||||||
|
* sort, otherwise the native select has no matching option.
|
||||||
|
* @param {string} sortValue - Sort value like "name:asc" or "random:<seed>"
|
||||||
|
*/
|
||||||
|
applySortToSelect(sortValue) {
|
||||||
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
|
if (!sortSelect) return;
|
||||||
|
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
|
||||||
|
if (randomOpt) {
|
||||||
|
randomOpt.value = String(sortValue).startsWith('random') ? sortValue : 'random';
|
||||||
|
}
|
||||||
|
sortSelect.value = sortValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fresh seeded random sort value ("random:<seed>") and keep
|
||||||
|
* the native <select> in sync so its value matches the persisted sort
|
||||||
|
* string and the dropdown shows the selected label.
|
||||||
|
* @returns {string} The new sort value, e.g. "random:abc123xyz"
|
||||||
|
*/
|
||||||
|
_randomizeSortValue() {
|
||||||
|
const seed = Math.random().toString(36).slice(2, 12);
|
||||||
|
const value = `random:${seed}`;
|
||||||
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
|
if (sortSelect) {
|
||||||
|
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
|
||||||
|
if (randomOpt) {
|
||||||
|
randomOpt.value = value;
|
||||||
|
}
|
||||||
|
sortSelect.value = value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load sort preference from storage
|
* Load sort preference from storage
|
||||||
*/
|
*/
|
||||||
@@ -326,10 +374,7 @@ export class PageControls {
|
|||||||
// Handle legacy format conversion
|
// Handle legacy format conversion
|
||||||
const convertedSort = this.convertLegacySortFormat(savedSort);
|
const convertedSort = this.convertLegacySortFormat(savedSort);
|
||||||
this.pageState.sortBy = convertedSort;
|
this.pageState.sortBy = convertedSort;
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
this.applySortToSelect(convertedSort);
|
||||||
if (sortSelect) {
|
|
||||||
sortSelect.value = convertedSort;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,9 +568,9 @@ export class PageControls {
|
|||||||
this.pageState.sortBy = restoredSort;
|
this.pageState.sortBy = restoredSort;
|
||||||
this.saveSortPreference(restoredSort);
|
this.saveSortPreference(restoredSort);
|
||||||
this._removeVlmSortOption();
|
this._removeVlmSortOption();
|
||||||
|
this.applySortToSelect(restoredSort);
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
sortSelect.value = restoredSort;
|
|
||||||
sortSelect.disabled = false;
|
sortSelect.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -575,10 +620,7 @@ export class PageControls {
|
|||||||
const savedGroupedSort = getStorageItem(groupedKey);
|
const savedGroupedSort = getStorageItem(groupedKey);
|
||||||
if (savedGroupedSort) {
|
if (savedGroupedSort) {
|
||||||
this.pageState.sortBy = savedGroupedSort;
|
this.pageState.sortBy = savedGroupedSort;
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
this.applySortToSelect(savedGroupedSort);
|
||||||
if (sortSelect) {
|
|
||||||
sortSelect.value = savedGroupedSort;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Leaving group mode: persist current sort for next time, restore non-group sort
|
// Leaving group mode: persist current sort for next time, restore non-group sort
|
||||||
@@ -586,10 +628,7 @@ export class PageControls {
|
|||||||
const savedNormalSort = getStorageItem(`${this.pageType}_sort`);
|
const savedNormalSort = getStorageItem(`${this.pageType}_sort`);
|
||||||
if (savedNormalSort) {
|
if (savedNormalSort) {
|
||||||
this.pageState.sortBy = savedNormalSort;
|
this.pageState.sortBy = savedNormalSort;
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
this.applySortToSelect(savedNormalSort);
|
||||||
if (sortSelect) {
|
|
||||||
sortSelect.value = savedNormalSort;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -874,7 +913,7 @@ export class PageControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
sortSelect.value = this.pageState.sortBy;
|
this.applySortToSelect(this.pageState.sortBy);
|
||||||
}
|
}
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.value = this.pageState.filters?.search || '';
|
searchInput.value = this.pageState.filters?.search || '';
|
||||||
|
|||||||
@@ -96,7 +96,16 @@ export function initSortDropdown(select) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const choose = (value) => {
|
const choose = (value) => {
|
||||||
if (select.value === value) return;
|
if (select.value === value) {
|
||||||
|
// Re-picking the already-selected option is normally a no-op,
|
||||||
|
// matching native <select> behavior. The seeded Random sort is
|
||||||
|
// the exception: clicking it again should reshuffle, so let the
|
||||||
|
// change handler (PageControls) generate a fresh seed.
|
||||||
|
if (String(value).startsWith('random')) {
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
select.value = value;
|
select.value = value;
|
||||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
};
|
};
|
||||||
@@ -277,9 +286,10 @@ export function initSortDropdown(select) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild the menu when <option>s change (VLM adds/removes a temporary
|
// Rebuild the menu when <option>s change (VLM adds/removes a temporary
|
||||||
// option at runtime).
|
// option at runtime, and the seeded Random sort option gets a new value
|
||||||
|
// attribute each time it is picked).
|
||||||
const observer = new MutationObserver(() => buildMenu());
|
const observer = new MutationObserver(() => buildMenu());
|
||||||
observer.observe(select, { childList: true });
|
observer.observe(select, { childList: true, subtree: true, attributes: true, attributeFilter: ['value'] });
|
||||||
|
|
||||||
buildMenu();
|
buildMenu();
|
||||||
group.dataset.sortReady = '1';
|
group.dataset.sortReady = '1';
|
||||||
|
|||||||
@@ -489,6 +489,12 @@ export function createModelCard(model, modelType) {
|
|||||||
const modelId = civitaiData?.modelId ?? civitaiData?.model_id;
|
const modelId = civitaiData?.modelId ?? civitaiData?.model_id;
|
||||||
if (modelId !== undefined && modelId !== null && modelId !== '') {
|
if (modelId !== undefined && modelId !== null && modelId !== '') {
|
||||||
card.dataset.modelId = modelId;
|
card.dataset.modelId = modelId;
|
||||||
|
} else if (model.hf_url) {
|
||||||
|
// For HF-only models, derive a group key from hf_url for version grouping
|
||||||
|
const match = model.hf_url.match(/https?:\/\/huggingface\.co\/([^/]+\/[^/]+)/);
|
||||||
|
if (match) {
|
||||||
|
card.dataset.modelId = 'hf:' + match[1];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoRA specific data
|
// LoRA specific data
|
||||||
|
|||||||
@@ -473,7 +473,14 @@ export async function showModelModal(model, modelType) {
|
|||||||
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
|
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
|
||||||
|
|
||||||
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
|
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
|
||||||
const civitaiModelId = modelWithFullData.civitai?.modelId || '';
|
// Use CivitAI modelId, or derive HF group key for HF-only models
|
||||||
|
let civitaiModelId = modelWithFullData.civitai?.modelId || '';
|
||||||
|
if (!civitaiModelId && modelWithFullData.hf_url) {
|
||||||
|
const match = modelWithFullData.hf_url.match(/https?:\/\/huggingface\.co\/([^/]+\/[^/]+)/);
|
||||||
|
if (match) {
|
||||||
|
civitaiModelId = 'hf:' + match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
const civitaiVersionId = modelWithFullData.civitai?.id || '';
|
const civitaiVersionId = modelWithFullData.civitai?.id || '';
|
||||||
const navAriaLabel = translate('modals.model.navigation.label', {}, 'Model navigation');
|
const navAriaLabel = translate('modals.model.navigation.label', {}, 'Model navigation');
|
||||||
const previousTitle = translate('modals.model.navigation.previousWithShortcut', {}, 'Previous model (←)');
|
const previousTitle = translate('modals.model.navigation.previousWithShortcut', {}, 'Previous model (←)');
|
||||||
@@ -885,7 +892,8 @@ function setupEventHandlers(filePath, modelType) {
|
|||||||
case 'view-creator':
|
case 'view-creator':
|
||||||
const username = target.dataset.username;
|
const username = target.dataset.username;
|
||||||
if (username) {
|
if (username) {
|
||||||
window.open(`https://civitai.com/user/${username}`, '_blank');
|
const host = state.global.settings.civitai_host || 'civitai.com';
|
||||||
|
window.open(`https://${host}/user/${username}`, '_blank');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'open-file-location':
|
case 'open-file-location':
|
||||||
|
|||||||
@@ -950,6 +950,26 @@ export function initVersionsTab({
|
|||||||
renderErrorState(container, translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.'));
|
renderErrorState(container, translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// HF group keys (e.g. "hf:user/repo") are not real CivitAI model IDs —
|
||||||
|
// skip the remote API call and show a helpful message instead.
|
||||||
|
const isHfGroupKey = typeof modelId === 'string' && modelId.startsWith('hf:');
|
||||||
|
if (isHfGroupKey) {
|
||||||
|
controller.isLoading = false;
|
||||||
|
controller.hasLoaded = true;
|
||||||
|
controller.record = null;
|
||||||
|
const hfMsg = translate(
|
||||||
|
'modals.model.versions.hfGroupInfo',
|
||||||
|
{},
|
||||||
|
'This is a HuggingFace model group. Open the library to see all versions in the grid.'
|
||||||
|
);
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="versions-empty-state">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<p>${escapeHtml(hfMsg)}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (controller.hasLoaded && !forceRefresh) {
|
if (controller.hasLoaded && !forceRefresh) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export class BulkManager {
|
|||||||
|
|
||||||
// Drag detection properties
|
// Drag detection properties
|
||||||
this.dragThreshold = 5; // Pixels to move before considering it a drag
|
this.dragThreshold = 5; // Pixels to move before considering it a drag
|
||||||
|
this.dragDelayMs = 100; // Minimum hold time before a drag is treated as a marquee
|
||||||
|
this.minMarqueeSize = 10; // Minimum drag box (px) before a marquee counts as a selection
|
||||||
this.mouseDownTime = 0;
|
this.mouseDownTime = 0;
|
||||||
this.mouseDownPosition = { x: 0, y: 0 };
|
this.mouseDownPosition = { x: 0, y: 0 };
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ export class BulkManager {
|
|||||||
moveAll: true,
|
moveAll: true,
|
||||||
autoOrganize: false,
|
autoOrganize: false,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: false,
|
setContentRating: true,
|
||||||
skipMetadataRefresh: false,
|
skipMetadataRefresh: false,
|
||||||
setFavorite: true,
|
setFavorite: true,
|
||||||
unfavorite: true,
|
unfavorite: true,
|
||||||
@@ -173,6 +175,19 @@ export class BulkManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
|
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
|
||||||
|
// Only track marquee/drag while the left button is physically held.
|
||||||
|
// mouseup can be missed (release outside the window, focus loss, driver quirks),
|
||||||
|
// so mousemove must verify the button state itself instead of relying on it.
|
||||||
|
if (!(e.buttons & 1)) {
|
||||||
|
if (this.isMarqueeActive) {
|
||||||
|
this.endMarqueeSelection(e);
|
||||||
|
} else {
|
||||||
|
this.mouseDownTime = 0;
|
||||||
|
this.isDragging = false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isMarqueeActive) {
|
if (this.isMarqueeActive) {
|
||||||
this.lastClientX = e.clientX;
|
this.lastClientX = e.clientX;
|
||||||
this.lastClientY = e.clientY;
|
this.lastClientY = e.clientY;
|
||||||
@@ -184,7 +199,10 @@ export class BulkManager {
|
|||||||
const dy = e.clientY - this.mouseDownPosition.y;
|
const dy = e.clientY - this.mouseDownPosition.y;
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
if (distance >= this.dragThreshold) {
|
// Require both enough movement AND enough hold time so quick
|
||||||
|
// click jitter from micro-movement input devices is not a marquee.
|
||||||
|
const heldTime = Date.now() - this.mouseDownTime;
|
||||||
|
if (heldTime >= this.dragDelayMs && distance >= this.dragThreshold) {
|
||||||
this.isDragging = true;
|
this.isDragging = true;
|
||||||
this.startMarqueeSelection(e, true);
|
this.startMarqueeSelection(e, true);
|
||||||
}
|
}
|
||||||
@@ -397,6 +415,7 @@ export class BulkManager {
|
|||||||
const updated = {
|
const updated = {
|
||||||
...existing,
|
...existing,
|
||||||
fileName: card.dataset.file_name ?? existing.fileName,
|
fileName: card.dataset.file_name ?? existing.fileName,
|
||||||
|
folder: card.dataset.folder ?? existing.folder,
|
||||||
usageTips: card.dataset.usage_tips ?? existing.usageTips,
|
usageTips: card.dataset.usage_tips ?? existing.usageTips,
|
||||||
modelName: card.dataset.name ?? existing.modelName,
|
modelName: card.dataset.name ?? existing.modelName,
|
||||||
};
|
};
|
||||||
@@ -494,7 +513,8 @@ export class BulkManager {
|
|||||||
|
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
||||||
loraSyntaxes.push(buildLoraSyntax(metadata.fileName, usageTips));
|
const loraName = metadata.folder ? `${metadata.folder}/${metadata.fileName}` : metadata.fileName;
|
||||||
|
loraSyntaxes.push(buildLoraSyntax(loraName, usageTips));
|
||||||
} else {
|
} else {
|
||||||
missingLoras.push(filepath);
|
missingLoras.push(filepath);
|
||||||
}
|
}
|
||||||
@@ -537,7 +557,8 @@ export class BulkManager {
|
|||||||
|
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
||||||
loraSyntaxes.push(buildLoraSyntax(metadata.fileName, usageTips));
|
const loraName = metadata.folder ? `${metadata.folder}/${metadata.fileName}` : metadata.fileName;
|
||||||
|
loraSyntaxes.push(buildLoraSyntax(loraName, usageTips));
|
||||||
} else {
|
} else {
|
||||||
missingLoras.push(filepath);
|
missingLoras.push(filepath);
|
||||||
}
|
}
|
||||||
@@ -553,7 +574,8 @@ export class BulkManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendLoraToWorkflow(loraSyntaxes.join(', '), replaceMode, 'lora');
|
const exitBulkMode = () => { if (state.bulkMode) this.toggleBulkMode(); };
|
||||||
|
await sendLoraToWorkflow(loraSyntaxes.join(', '), replaceMode, 'lora', exitBulkMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _sendAllEmbeddingsToWorkflow() {
|
async _sendAllEmbeddingsToWorkflow() {
|
||||||
@@ -575,7 +597,8 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const joinedCode = embeddingCodes.join(', ');
|
const joinedCode = embeddingCodes.join(', ');
|
||||||
await sendEmbeddingToWorkflow(joinedCode);
|
const exitBulkMode = () => { if (state.bulkMode) this.toggleBulkMode(); };
|
||||||
|
await sendEmbeddingToWorkflow(joinedCode, exitBulkMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
showBulkDeleteModal() {
|
showBulkDeleteModal() {
|
||||||
@@ -674,6 +697,7 @@ export class BulkManager {
|
|||||||
const modelId = this.parseModelId(item?.civitai?.modelId);
|
const modelId = this.parseModelId(item?.civitai?.modelId);
|
||||||
metadataCache.set(item.file_path, {
|
metadataCache.set(item.file_path, {
|
||||||
fileName: item.file_name,
|
fileName: item.file_name,
|
||||||
|
folder: item.folder || '',
|
||||||
usageTips: item.usage_tips || '{}',
|
usageTips: item.usage_tips || '{}',
|
||||||
modelName: item.name || item.file_name,
|
modelName: item.name || item.file_name,
|
||||||
...(modelId !== null ? { modelId } : {})
|
...(modelId !== null ? { modelId } : {})
|
||||||
@@ -1504,14 +1528,18 @@ export class BulkManager {
|
|||||||
let failureCount = 0;
|
let failureCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiClient = getModelApiClient();
|
const isRecipesPage = state.currentPageType === 'recipes';
|
||||||
for (const filePath of targets) {
|
for (const filePath of targets) {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
showToast('toast.api.operationCancelled', {}, 'info');
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await apiClient.saveModelMetadata(filePath, { preview_nsfw_level: level });
|
if (isRecipesPage) {
|
||||||
|
await updateRecipeMetadata(filePath, { preview_nsfw_level: level });
|
||||||
|
} else {
|
||||||
|
await getModelApiClient().saveModelMetadata(filePath, { preview_nsfw_level: level });
|
||||||
|
}
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failureCount++;
|
failureCount++;
|
||||||
@@ -1659,13 +1687,19 @@ export class BulkManager {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isRecipesPage = state.currentPageType === 'recipes';
|
||||||
|
|
||||||
for (const filepath of state.selectedModels) {
|
for (const filepath of state.selectedModels) {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
showToast('toast.api.operationCancelled', {}, 'info');
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (isRecipesPage) {
|
||||||
|
await updateRecipeMetadata(filepath, { base_model: newBaseModel });
|
||||||
|
} else {
|
||||||
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel });
|
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel });
|
||||||
|
}
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
@@ -1946,9 +1980,31 @@ export class BulkManager {
|
|||||||
// Remove visual feedback class
|
// Remove visual feedback class
|
||||||
document.body.classList.remove('marquee-selecting');
|
document.body.classList.remove('marquee-selecting');
|
||||||
|
|
||||||
|
// Compute the actual drag box size in document coordinates, matching how
|
||||||
|
// updateMarqueeSelectionFromPosition tracks the rectangle. Client-space
|
||||||
|
// size would wrongly flag auto-scroll marquees (tiny pointer movement,
|
||||||
|
// large document-space box) as accidental clicks.
|
||||||
|
const container = document.querySelector('.page-content');
|
||||||
|
const scrollX = container?.scrollLeft || 0;
|
||||||
|
const scrollY = container?.scrollTop || 0;
|
||||||
|
const dragWidth = Math.abs((e.clientX + scrollX) - this.marqueeStartDoc.x);
|
||||||
|
const dragHeight = Math.abs((e.clientY + scrollY) - this.marqueeStartDoc.y);
|
||||||
|
const isTinyMarquee = dragWidth < this.minMarqueeSize && dragHeight < this.minMarqueeSize;
|
||||||
|
|
||||||
// Get selection count
|
// Get selection count
|
||||||
const selectionCount = state.selectedModels.size;
|
const selectionCount = state.selectedModels.size;
|
||||||
|
|
||||||
|
// A tiny box (e.g. click jitter that happened to graze a card) is treated
|
||||||
|
// as an accidental click: undo any selection and leave bulk mode.
|
||||||
|
if (isTinyMarquee) {
|
||||||
|
this.clearSelection();
|
||||||
|
if (state.bulkMode) {
|
||||||
|
this.toggleBulkMode();
|
||||||
|
}
|
||||||
|
this.initialSelectedModels.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If no models were selected, exit bulk mode
|
// If no models were selected, exit bulk mode
|
||||||
if (selectionCount === 0) {
|
if (selectionCount === 0) {
|
||||||
if (state.bulkMode) {
|
if (state.bulkMode) {
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export class DownloadManager {
|
|||||||
this.modelVersionId = null;
|
this.modelVersionId = null;
|
||||||
this.source = null;
|
this.source = null;
|
||||||
this.selectedFile = null;
|
this.selectedFile = null;
|
||||||
|
this._isDiffusionModel = false;
|
||||||
|
|
||||||
this.selectedFolder = '';
|
this.selectedFolder = '';
|
||||||
this.batchModels = [];
|
this.batchModels = [];
|
||||||
@@ -787,24 +788,40 @@ export class DownloadManager {
|
|||||||
async proceedToLocationContent() {
|
async proceedToLocationContent() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch model roots
|
const _isDiffusionModel = this.selectedFile
|
||||||
const rootsData = await this.apiClient.fetchModelRoots();
|
? (this.selectedFile.type === 'UNet' || this.selectedFile.type === 'Diffusion Model')
|
||||||
|
: (this.currentVersion?.files || []).some(
|
||||||
|
f => f.type === 'UNet' || f.type === 'Diffusion Model'
|
||||||
|
);
|
||||||
|
this._isDiffusionModel = _isDiffusionModel;
|
||||||
|
|
||||||
|
let rootsData;
|
||||||
|
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
|
||||||
|
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
|
||||||
|
} else {
|
||||||
|
rootsData = await this.apiClient.fetchModelRoots();
|
||||||
|
}
|
||||||
const modelRoot = document.getElementById('modelRoot');
|
const modelRoot = document.getElementById('modelRoot');
|
||||||
modelRoot.innerHTML = rootsData.roots.map(root =>
|
modelRoot.innerHTML = rootsData.roots.map(root =>
|
||||||
`<option value="${root}">${root}</option>`
|
`<option value="${root}">${root}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
// Set default root if available
|
const singularType = this._isDiffusionModel
|
||||||
const singularType = this.apiClient.modelType.replace(/s$/, '');
|
? 'unet'
|
||||||
|
: this.apiClient.modelType.replace(/s$/, '');
|
||||||
const defaultRootKey = `default_${singularType}_root`;
|
const defaultRootKey = `default_${singularType}_root`;
|
||||||
const defaultRoot = state.global.settings[defaultRootKey];
|
const defaultRoot = state.global.settings[defaultRootKey];
|
||||||
console.log(`Default root for ${this.apiClient.modelType}:`, defaultRoot);
|
console.log(`Default root for ${singularType}:`, defaultRoot);
|
||||||
console.log('Available roots:', rootsData.roots);
|
console.log('Available roots:', rootsData.roots);
|
||||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||||
console.log(`Setting default root: ${defaultRoot}`);
|
console.log(`Setting default root: ${defaultRoot}`);
|
||||||
modelRoot.value = defaultRoot;
|
modelRoot.value = defaultRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
|
||||||
|
document.getElementById('modelRootLabel').textContent =
|
||||||
|
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
|
||||||
|
|
||||||
// Set autocomplete="off" on folderPath input
|
// Set autocomplete="off" on folderPath input
|
||||||
const folderPathInput = document.getElementById('folderPath');
|
const folderPathInput = document.getElementById('folderPath');
|
||||||
if (folderPathInput) {
|
if (folderPathInput) {
|
||||||
@@ -1776,13 +1793,15 @@ export class DownloadManager {
|
|||||||
const modelRoot = document.getElementById('modelRoot').value;
|
const modelRoot = document.getElementById('modelRoot').value;
|
||||||
const config = this.apiClient.apiConfig.config;
|
const config = this.apiClient.apiConfig.config;
|
||||||
|
|
||||||
let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: config.displayName });
|
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : config.displayName;
|
||||||
|
let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
|
||||||
|
|
||||||
if (modelRoot) {
|
if (modelRoot) {
|
||||||
if (this.useDefaultPath) {
|
if (this.useDefaultPath) {
|
||||||
// Show actual template path
|
|
||||||
try {
|
try {
|
||||||
const singularType = this.apiClient.modelType.replace(/s$/, '');
|
const singularType = this._isDiffusionModel
|
||||||
|
? 'unet'
|
||||||
|
: this.apiClient.modelType.replace(/s$/, '');
|
||||||
const templates = state.global.settings.download_path_templates;
|
const templates = state.global.settings.download_path_templates;
|
||||||
const template = templates[singularType];
|
const template = templates[singularType];
|
||||||
fullPath += `/${template}`;
|
fullPath += `/${template}`;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getCurrentPageState } from '../state/index.js';
|
import { getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
|
import { getApiEndpoints } from '../api/apiConfig.js';
|
||||||
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
@@ -24,6 +25,12 @@ export class FilterManager {
|
|||||||
this.baseModelOptions = [];
|
this.baseModelOptions = [];
|
||||||
this.tagsLoaded = false;
|
this.tagsLoaded = false;
|
||||||
|
|
||||||
|
// Tag search state
|
||||||
|
this.modelTagsSearchInput = document.getElementById('modelTagsSearchInput');
|
||||||
|
this.tagSearchDebounceTimer = null;
|
||||||
|
this.tagSearchAbortController = null;
|
||||||
|
this.tagSearchQuery = '';
|
||||||
|
|
||||||
// Initialize preset manager
|
// Initialize preset manager
|
||||||
this.presetManager = new FilterPresetManager({
|
this.presetManager = new FilterPresetManager({
|
||||||
page: this.currentPage,
|
page: this.currentPage,
|
||||||
@@ -123,6 +130,60 @@ export class FilterManager {
|
|||||||
this.renderBaseModelTags();
|
this.renderBaseModelTags();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.modelTagsSearchInput) {
|
||||||
|
this.modelTagsSearchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(this.tagSearchDebounceTimer);
|
||||||
|
this.tagSearchDebounceTimer = setTimeout(() => {
|
||||||
|
this.handleTagSearchInput();
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTagSearchInput() {
|
||||||
|
const query = (this.modelTagsSearchInput?.value || '').trim();
|
||||||
|
const trimmedQuery = query.toLowerCase();
|
||||||
|
if (trimmedQuery === this.tagSearchQuery) return;
|
||||||
|
this.tagSearchQuery = trimmedQuery;
|
||||||
|
|
||||||
|
if (!trimmedQuery) {
|
||||||
|
// Empty query: reload top tags (default/common view)
|
||||||
|
this.loadTopTags();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.searchTags(trimmedQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchTags(query) {
|
||||||
|
// Abort any in-flight search request
|
||||||
|
if (this.tagSearchAbortController) {
|
||||||
|
this.tagSearchAbortController.abort();
|
||||||
|
}
|
||||||
|
this.tagSearchAbortController = new AbortController();
|
||||||
|
const controller = this.tagSearchAbortController;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tagsEndpoint = `${getApiEndpoints(this.currentPage).searchTags}?q=${encodeURIComponent(query)}&limit=20`;
|
||||||
|
const response = await fetch(tagsEndpoint, { signal: controller.signal });
|
||||||
|
if (!response.ok) throw new Error('Failed to search tags');
|
||||||
|
const data = await response.json();
|
||||||
|
if (controller.signal.aborted) return; // stale response
|
||||||
|
if (data.success && data.tags) {
|
||||||
|
this.createTagFilterElements(data.tags);
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid response format');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') return; // expected, ignore
|
||||||
|
console.error('Error searching tags:', error);
|
||||||
|
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||||
|
if (tagsContainer) {
|
||||||
|
tagsContainer.innerHTML = '<div class="tags-error">Failed to search tags</div>';
|
||||||
|
}
|
||||||
|
const emptyState = document.getElementById('modelTagsEmptyState');
|
||||||
|
if (emptyState) emptyState.hidden = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getNormalizedSearchQuery(input) {
|
getNormalizedSearchQuery(input) {
|
||||||
@@ -146,15 +207,24 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadTopTags() {
|
async loadTopTags() {
|
||||||
|
// Abort any in-flight tag search request
|
||||||
|
if (this.tagSearchAbortController) {
|
||||||
|
this.tagSearchAbortController.abort();
|
||||||
|
this.tagSearchAbortController = null;
|
||||||
|
}
|
||||||
|
this.tagSearchQuery = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Show loading state
|
// Show loading state
|
||||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||||
|
const emptyState = document.getElementById('modelTagsEmptyState');
|
||||||
if (!tagsContainer) return;
|
if (!tagsContainer) return;
|
||||||
|
if (emptyState) emptyState.hidden = true;
|
||||||
|
|
||||||
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
||||||
|
|
||||||
// Determine the API endpoint based on the page type
|
// Determine the API endpoint based on the page type
|
||||||
const tagsEndpoint = `/api/lm/${this.currentPage}/top-tags?limit=20`;
|
const tagsEndpoint = `${getApiEndpoints(this.currentPage).topTags}?limit=20`;
|
||||||
|
|
||||||
const response = await fetch(tagsEndpoint);
|
const response = await fetch(tagsEndpoint);
|
||||||
if (!response.ok) throw new Error('Failed to fetch tags');
|
if (!response.ok) throw new Error('Failed to fetch tags');
|
||||||
@@ -179,29 +249,38 @@ export class FilterManager {
|
|||||||
|
|
||||||
createTagFilterElements(tags) {
|
createTagFilterElements(tags) {
|
||||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||||
|
const emptyState = document.getElementById('modelTagsEmptyState');
|
||||||
if (!tagsContainer) return;
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
tagsContainer.innerHTML = '';
|
tagsContainer.innerHTML = '';
|
||||||
|
if (emptyState) emptyState.hidden = true;
|
||||||
|
|
||||||
// Collect existing tag names from the API response
|
// Collect existing tag names from the API response
|
||||||
const existingTagNames = new Set(tags.map(t => t.tag));
|
const existingTagNames = new Set(tags.map(t => t.tag));
|
||||||
|
|
||||||
// Add any active filter tags that aren't in the top 20
|
// Collect active filter tags that aren't in the response (excluding __no_tags__)
|
||||||
|
const missingSelectedTags = [];
|
||||||
if (this.filters.tags) {
|
if (this.filters.tags) {
|
||||||
Object.keys(this.filters.tags).forEach(tagName => {
|
Object.keys(this.filters.tags).forEach(tagName => {
|
||||||
// Skip special tags like __no_tags__
|
|
||||||
if (tagName.startsWith('__')) return;
|
if (tagName.startsWith('__')) return;
|
||||||
|
|
||||||
if (!existingTagNames.has(tagName)) {
|
if (!existingTagNames.has(tagName)) {
|
||||||
// Add this tag to the list with count 0 (unknown)
|
missingSelectedTags.push({ tag: tagName, count: 0 });
|
||||||
tags.push({ tag: tagName, count: 0 });
|
|
||||||
existingTagNames.add(tagName);
|
existingTagNames.add(tagName);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append missing selected tags after the API results so they appear inline
|
||||||
|
for (const t of missingSelectedTags) {
|
||||||
|
tags.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
if (!tags.length) {
|
if (!tags.length) {
|
||||||
|
if (this.tagSearchQuery) {
|
||||||
|
if (emptyState) emptyState.hidden = false;
|
||||||
|
} else {
|
||||||
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +288,10 @@ export class FilterManager {
|
|||||||
const tagEl = document.createElement('div');
|
const tagEl = document.createElement('div');
|
||||||
tagEl.className = 'filter-tag tag-filter';
|
tagEl.className = 'filter-tag tag-filter';
|
||||||
const tagName = tag.tag;
|
const tagName = tag.tag;
|
||||||
|
|
||||||
|
if (missingSelectedTags.some(t => t.tag === tagName)) {
|
||||||
|
tagEl.classList.add('extra-tag');
|
||||||
|
}
|
||||||
tagEl.dataset.tag = tagName;
|
tagEl.dataset.tag = tagName;
|
||||||
|
|
||||||
// Show count only if it's > 0 (known count)
|
// Show count only if it's > 0 (known count)
|
||||||
@@ -234,7 +317,8 @@ export class FilterManager {
|
|||||||
tagsContainer.appendChild(tagEl);
|
tagsContainer.appendChild(tagEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add "No tags" as a special filter at the end
|
// Add "No tags" as a special filter at the end (skip during search)
|
||||||
|
if (!this.tagSearchQuery) {
|
||||||
const noTagsEl = document.createElement('div');
|
const noTagsEl = document.createElement('div');
|
||||||
noTagsEl.className = 'filter-tag tag-filter special-tag';
|
noTagsEl.className = 'filter-tag tag-filter special-tag';
|
||||||
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
|
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
|
||||||
@@ -254,6 +338,7 @@ export class FilterManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tagsContainer.appendChild(noTagsEl);
|
tagsContainer.appendChild(noTagsEl);
|
||||||
|
}
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +426,7 @@ export class FilterManager {
|
|||||||
if (!baseModelTagsContainer) return;
|
if (!baseModelTagsContainer) return;
|
||||||
|
|
||||||
// Set the API endpoint based on current page
|
// Set the API endpoint based on current page
|
||||||
const apiEndpoint = `/api/lm/${this.currentPage}/base-models?limit=0`;
|
const apiEndpoint = `${getApiEndpoints(this.currentPage).baseModels}?limit=0`;
|
||||||
|
|
||||||
// Fetch base models
|
// Fetch base models
|
||||||
fetch(apiEndpoint)
|
fetch(apiEndpoint)
|
||||||
@@ -644,10 +729,12 @@ export class FilterManager {
|
|||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
const storageKey = `${this.currentPage}_filters`;
|
const storageKey = `${this.currentPage}_filters`;
|
||||||
|
|
||||||
// Save filters to localStorage (exclude EMPTY_WILDCARD_MARKER)
|
// Save filters to localStorage (exclude EMPTY_WILDCARD_MARKER and transient search)
|
||||||
const filtersSnapshot = this.cloneFilters();
|
const filtersSnapshot = this.cloneFilters();
|
||||||
// Don't persist EMPTY_WILDCARD_MARKER - it's a runtime-only marker
|
// Don't persist EMPTY_WILDCARD_MARKER - it's a runtime-only marker
|
||||||
filtersSnapshot.baseModel = filtersSnapshot.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER);
|
filtersSnapshot.baseModel = filtersSnapshot.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER);
|
||||||
|
// Don't persist search - it's transient and managed by SearchManager
|
||||||
|
delete filtersSnapshot.search;
|
||||||
setStorageItem(storageKey, filtersSnapshot);
|
setStorageItem(storageKey, filtersSnapshot);
|
||||||
|
|
||||||
// Update state with current filters
|
// Update state with current filters
|
||||||
@@ -721,6 +808,16 @@ export class FilterManager {
|
|||||||
tagLogic: 'any'
|
tagLogic: 'any'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear tag search input and reset search state
|
||||||
|
if (this.modelTagsSearchInput) {
|
||||||
|
this.modelTagsSearchInput.value = '';
|
||||||
|
}
|
||||||
|
this.tagSearchQuery = '';
|
||||||
|
if (this.tagSearchAbortController) {
|
||||||
|
this.tagSearchAbortController.abort();
|
||||||
|
this.tagSearchAbortController = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Update tag logic toggle UI
|
// Update tag logic toggle UI
|
||||||
this.updateTagLogicToggleUI();
|
this.updateTagLogicToggleUI();
|
||||||
|
|
||||||
@@ -731,6 +828,10 @@ export class FilterManager {
|
|||||||
// Update UI
|
// Update UI
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
// Reload tag area to drop any non-top-20 tags from the deactivated preset
|
||||||
|
if (this.tagsLoaded) {
|
||||||
|
await this.loadTopTags();
|
||||||
|
}
|
||||||
this.presetManager.renderPresets(); // Re-render to remove active state
|
this.presetManager.renderPresets(); // Re-render to remove active state
|
||||||
|
|
||||||
// Remove from local Storage
|
// Remove from local Storage
|
||||||
@@ -885,6 +986,7 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cloneFilters() {
|
cloneFilters() {
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
return {
|
return {
|
||||||
...this.filters,
|
...this.filters,
|
||||||
baseModel: [...(this.filters.baseModel || [])],
|
baseModel: [...(this.filters.baseModel || [])],
|
||||||
@@ -892,7 +994,8 @@ export class FilterManager {
|
|||||||
autoTags: { ...(this.filters.autoTags || {}) },
|
autoTags: { ...(this.filters.autoTags || {}) },
|
||||||
license: { ...(this.filters.license || {}) },
|
license: { ...(this.filters.license || {}) },
|
||||||
modelTypes: [...(this.filters.modelTypes || [])],
|
modelTypes: [...(this.filters.modelTypes || [])],
|
||||||
tagLogic: this.filters.tagLogic || 'any'
|
tagLogic: this.filters.tagLogic || 'any',
|
||||||
|
search: pageState?.filters?.search ?? ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -478,11 +478,9 @@ export class FilterPresetManager {
|
|||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
pageState.filters = this.filterManager.cloneFilters();
|
pageState.filters = this.filterManager.cloneFilters();
|
||||||
|
|
||||||
// If tags haven't been loaded yet, load them first
|
// Refresh tag display so preset's non-top-20 tags appear inline
|
||||||
if (!this.filterManager.tagsLoaded) {
|
|
||||||
await this.filterManager.loadTopTags();
|
await this.filterManager.loadTopTags();
|
||||||
this.filterManager.tagsLoaded = true;
|
this.filterManager.tagsLoaded = true;
|
||||||
}
|
|
||||||
|
|
||||||
// Check again after async operation
|
// Check again after async operation
|
||||||
if (requestId !== this.applyPresetRequestId) return;
|
if (requestId !== this.applyPresetRequestId) return;
|
||||||
@@ -745,8 +743,16 @@ export class FilterPresetManager {
|
|||||||
presetEl.classList.add('active');
|
presetEl.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
presetEl.addEventListener('click', (e) => {
|
// Apply preset on click (toggle if already active)
|
||||||
e.stopPropagation();
|
// Bind to the whole .filter-preset div so clicking anywhere inside triggers apply
|
||||||
|
presetEl.addEventListener('click', async () => {
|
||||||
|
this.cancelPendingDelete();
|
||||||
|
|
||||||
|
if (this.activePreset === preset.name) {
|
||||||
|
await this.filterManager.clearFilters();
|
||||||
|
} else {
|
||||||
|
await this.applyPreset(preset.name);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const presetName = document.createElement('span');
|
const presetName = document.createElement('span');
|
||||||
@@ -759,18 +765,6 @@ export class FilterPresetManager {
|
|||||||
deleteBtn.innerHTML = '<i class="fas fa-times"></i>';
|
deleteBtn.innerHTML = '<i class="fas fa-times"></i>';
|
||||||
deleteBtn.title = translate('header.filter.presetDeleteTooltip', {}, 'Delete preset');
|
deleteBtn.title = translate('header.filter.presetDeleteTooltip', {}, 'Delete preset');
|
||||||
|
|
||||||
// Apply preset on name click (toggle if already active)
|
|
||||||
presetName.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
this.cancelPendingDelete();
|
|
||||||
|
|
||||||
if (this.activePreset === preset.name) {
|
|
||||||
await this.filterManager.clearFilters();
|
|
||||||
} else {
|
|
||||||
await this.applyPreset(preset.name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Two-step delete on delete button click
|
// Two-step delete on delete button click
|
||||||
deleteBtn.addEventListener('click', (e) => {
|
deleteBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -1517,11 +1517,20 @@ export class SettingsManager {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showNoRootsPlaceholder(select) {
|
||||||
|
select.innerHTML = '';
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = '';
|
||||||
|
option.textContent = translate('settings.folderSettings.noDefault', {}, 'No Default');
|
||||||
|
select.appendChild(option);
|
||||||
|
select.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
async loadLoraRoots() {
|
async loadLoraRoots() {
|
||||||
try {
|
|
||||||
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
|
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
|
||||||
if (!defaultLoraRootSelect) return;
|
if (!defaultLoraRootSelect) return;
|
||||||
|
|
||||||
|
try {
|
||||||
// Fetch lora roots
|
// Fetch lora roots
|
||||||
const response = await fetch('/api/lm/loras/roots');
|
const response = await fetch('/api/lm/loras/roots');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1530,10 +1539,12 @@ export class SettingsManager {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data.roots || data.roots.length === 0) {
|
if (!data.roots || data.roots.length === 0) {
|
||||||
throw new Error('No LoRA roots found');
|
this.showNoRootsPlaceholder(defaultLoraRootSelect);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultLoraRootSelect.innerHTML = '';
|
defaultLoraRootSelect.innerHTML = '';
|
||||||
|
defaultLoraRootSelect.disabled = false;
|
||||||
|
|
||||||
// Add options for each root
|
// Add options for each root
|
||||||
data.roots.forEach(root => {
|
data.roots.forEach(root => {
|
||||||
@@ -1548,15 +1559,16 @@ export class SettingsManager {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading LoRA roots:', error);
|
console.error('Error loading LoRA roots:', error);
|
||||||
|
this.showNoRootsPlaceholder(defaultLoraRootSelect);
|
||||||
showToast('toast.settings.loraRootsFailed', { message: error.message }, 'error');
|
showToast('toast.settings.loraRootsFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCheckpointRoots() {
|
async loadCheckpointRoots() {
|
||||||
try {
|
|
||||||
const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot');
|
const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot');
|
||||||
if (!defaultCheckpointRootSelect) return;
|
if (!defaultCheckpointRootSelect) return;
|
||||||
|
|
||||||
|
try {
|
||||||
// Fetch checkpoint roots (checkpoint paths only, not unet)
|
// Fetch checkpoint roots (checkpoint paths only, not unet)
|
||||||
const response = await fetch('/api/lm/checkpoints/checkpoints_roots');
|
const response = await fetch('/api/lm/checkpoints/checkpoints_roots');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1565,10 +1577,12 @@ export class SettingsManager {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data.roots || data.roots.length === 0) {
|
if (!data.roots || data.roots.length === 0) {
|
||||||
throw new Error('No checkpoint roots found');
|
this.showNoRootsPlaceholder(defaultCheckpointRootSelect);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultCheckpointRootSelect.innerHTML = '';
|
defaultCheckpointRootSelect.innerHTML = '';
|
||||||
|
defaultCheckpointRootSelect.disabled = false;
|
||||||
|
|
||||||
// Add options for each root
|
// Add options for each root
|
||||||
data.roots.forEach(root => {
|
data.roots.forEach(root => {
|
||||||
@@ -1583,15 +1597,16 @@ export class SettingsManager {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading checkpoint roots:', error);
|
console.error('Error loading checkpoint roots:', error);
|
||||||
|
this.showNoRootsPlaceholder(defaultCheckpointRootSelect);
|
||||||
showToast('toast.settings.checkpointRootsFailed', { message: error.message }, 'error');
|
showToast('toast.settings.checkpointRootsFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadUnetRoots() {
|
async loadUnetRoots() {
|
||||||
try {
|
|
||||||
const defaultUnetRootSelect = document.getElementById('defaultUnetRoot');
|
const defaultUnetRootSelect = document.getElementById('defaultUnetRoot');
|
||||||
if (!defaultUnetRootSelect) return;
|
if (!defaultUnetRootSelect) return;
|
||||||
|
|
||||||
|
try {
|
||||||
// Fetch unet roots (diffusion model paths only)
|
// Fetch unet roots (diffusion model paths only)
|
||||||
const response = await fetch('/api/lm/checkpoints/unet_roots');
|
const response = await fetch('/api/lm/checkpoints/unet_roots');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1600,10 +1615,12 @@ export class SettingsManager {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data.roots || data.roots.length === 0) {
|
if (!data.roots || data.roots.length === 0) {
|
||||||
throw new Error('No diffusion model roots found');
|
this.showNoRootsPlaceholder(defaultUnetRootSelect);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultUnetRootSelect.innerHTML = '';
|
defaultUnetRootSelect.innerHTML = '';
|
||||||
|
defaultUnetRootSelect.disabled = false;
|
||||||
|
|
||||||
// Add options for each root
|
// Add options for each root
|
||||||
data.roots.forEach(root => {
|
data.roots.forEach(root => {
|
||||||
@@ -1618,15 +1635,16 @@ export class SettingsManager {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading diffusion model roots:', error);
|
console.error('Error loading diffusion model roots:', error);
|
||||||
|
this.showNoRootsPlaceholder(defaultUnetRootSelect);
|
||||||
showToast('toast.settings.unetRootsFailed', { message: error.message }, 'error');
|
showToast('toast.settings.unetRootsFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadEmbeddingRoots() {
|
async loadEmbeddingRoots() {
|
||||||
try {
|
|
||||||
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
|
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
|
||||||
if (!defaultEmbeddingRootSelect) return;
|
if (!defaultEmbeddingRootSelect) return;
|
||||||
|
|
||||||
|
try {
|
||||||
// Fetch embedding roots
|
// Fetch embedding roots
|
||||||
const response = await fetch('/api/lm/embeddings/roots');
|
const response = await fetch('/api/lm/embeddings/roots');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1635,10 +1653,12 @@ export class SettingsManager {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data.roots || data.roots.length === 0) {
|
if (!data.roots || data.roots.length === 0) {
|
||||||
throw new Error('No embedding roots found');
|
this.showNoRootsPlaceholder(defaultEmbeddingRootSelect);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultEmbeddingRootSelect.innerHTML = '';
|
defaultEmbeddingRootSelect.innerHTML = '';
|
||||||
|
defaultEmbeddingRootSelect.disabled = false;
|
||||||
|
|
||||||
// Add options for each root
|
// Add options for each root
|
||||||
data.roots.forEach(root => {
|
data.roots.forEach(root => {
|
||||||
@@ -1653,6 +1673,7 @@ export class SettingsManager {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading embedding roots:', error);
|
console.error('Error loading embedding roots:', error);
|
||||||
|
this.showNoRootsPlaceholder(defaultEmbeddingRootSelect);
|
||||||
showToast('toast.settings.embeddingRootsFailed', { message: error.message }, 'error');
|
showToast('toast.settings.embeddingRootsFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2346,6 +2367,16 @@ export class SettingsManager {
|
|||||||
enableMetadataArchiveCheckbox.checked = state.global.settings.enable_metadata_archive_db || false;
|
enableMetadataArchiveCheckbox.checked = state.global.settings.enable_metadata_archive_db || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enableCivarchiveApiCheckbox = document.getElementById('enableCivarchiveApi');
|
||||||
|
if (enableCivarchiveApiCheckbox) {
|
||||||
|
enableCivarchiveApiCheckbox.checked = state.global.settings.enable_civarchive_api ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataProviderOrderSelect = document.getElementById('metadataProviderOrder');
|
||||||
|
if (metadataProviderOrderSelect) {
|
||||||
|
metadataProviderOrderSelect.value = state.global.settings.metadata_provider_order || 'civitai_archive_sqlite';
|
||||||
|
}
|
||||||
|
|
||||||
// Load status
|
// Load status
|
||||||
await this.updateMetadataArchiveStatus();
|
await this.updateMetadataArchiveStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
setStoredVersionInfo,
|
setStoredVersionInfo,
|
||||||
isVersionMatch
|
isVersionMatch
|
||||||
} from '../utils/storageHelpers.js';
|
} from '../utils/storageHelpers.js';
|
||||||
|
import { state } from '../state/index.js';
|
||||||
import { bannerService } from './BannerService.js';
|
import { bannerService } from './BannerService.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
|
||||||
@@ -24,7 +25,11 @@ export class UpdateService {
|
|||||||
this.updateNotificationsEnabled = getStorageItem('show_update_notifications', true);
|
this.updateNotificationsEnabled = getStorageItem('show_update_notifications', true);
|
||||||
this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0');
|
this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0');
|
||||||
this.isUpdating = false;
|
this.isUpdating = false;
|
||||||
this.nightlyMode = getStorageItem('nightly_updates', false);
|
this.channelMode = null;
|
||||||
|
this.hasGit = false;
|
||||||
|
this.nightlyNotifyDate = getStorageItem('nightly_notify_date', '');
|
||||||
|
this.nightlyBadgeShown = false;
|
||||||
|
this.progressKeepVisible = false;
|
||||||
this.currentVersionInfo = null;
|
this.currentVersionInfo = null;
|
||||||
this.versionMismatch = false;
|
this.versionMismatch = false;
|
||||||
this.activeNotificationTab = 'updates';
|
this.activeNotificationTab = 'updates';
|
||||||
@@ -49,43 +54,180 @@ export class UpdateService {
|
|||||||
updateBtn.addEventListener('click', () => this.performUpdate());
|
updateBtn.addEventListener('click', () => this.performUpdate());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register event listener for nightly update toggle
|
this.wireChannelButtons();
|
||||||
const nightlyCheckbox = document.getElementById('nightlyUpdateToggle');
|
|
||||||
if (nightlyCheckbox) {
|
|
||||||
nightlyCheckbox.checked = this.nightlyMode;
|
|
||||||
nightlyCheckbox.addEventListener('change', (e) => {
|
|
||||||
this.nightlyMode = e.target.checked;
|
|
||||||
setStorageItem('nightly_updates', e.target.checked);
|
|
||||||
this.updateNightlyWarning();
|
|
||||||
this.updateModalContent();
|
|
||||||
// Re-check for updates when switching channels
|
|
||||||
this.manualCheckForUpdates();
|
|
||||||
});
|
|
||||||
this.updateNightlyWarning();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setupNotificationCenter();
|
this.setupNotificationCenter();
|
||||||
window.addEventListener('lm:banner-history-updated', this.handleBannerHistoryUpdated);
|
window.addEventListener('lm:banner-history-updated', this.handleBannerHistoryUpdated);
|
||||||
this.updateTabBadges();
|
this.updateTabBadges();
|
||||||
|
|
||||||
// Perform update check if needed
|
// Perform update check if needed
|
||||||
|
this.checkVersionInfo().then(() => {
|
||||||
this.checkForUpdates().then(() => {
|
this.checkForUpdates().then(() => {
|
||||||
// Ensure badges are updated after checking
|
|
||||||
this.updateBadgeVisibility();
|
this.updateBadgeVisibility();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Immediately update modal content with current values (even if from default)
|
|
||||||
this.updateModalContent();
|
this.updateModalContent();
|
||||||
|
|
||||||
// Check version info for mismatch after loading basic info
|
|
||||||
this.checkVersionInfo();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateNightlyWarning() {
|
wireChannelButtons() {
|
||||||
const warning = document.getElementById('nightlyWarning');
|
const releaseBtn = document.getElementById('channelRelease');
|
||||||
if (warning) {
|
const nightlyBtn = document.getElementById('channelNightly');
|
||||||
warning.style.display = this.nightlyMode ? 'flex' : 'none';
|
if (releaseBtn) {
|
||||||
|
releaseBtn.addEventListener('click', () => this.switchChannel('release'));
|
||||||
}
|
}
|
||||||
|
if (nightlyBtn) {
|
||||||
|
nightlyBtn.addEventListener('click', () => this.switchChannel('nightly'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchChannel(channel) {
|
||||||
|
if (channel === this.channelMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isUpdating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.hasGit && channel === 'nightly') {
|
||||||
|
const confirmed = await this._confirmChannelSwitch(
|
||||||
|
'update.channelSwitch.nightlyTitle',
|
||||||
|
'update.channelSwitch.nightlyMessage'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
if (this.hasGit && channel === 'release') {
|
||||||
|
const confirmed = await this._confirmChannelSwitch(
|
||||||
|
'update.channelSwitch.releaseTitle',
|
||||||
|
'update.channelSwitch.releaseMessage'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.isUpdating = true;
|
||||||
|
this.showUpdateProgress(true);
|
||||||
|
this.updateProgress(10, translate('update.channelSwitch.switching', { channel }));
|
||||||
|
|
||||||
|
const response = await fetch('/api/lm/switch-channel', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ channel })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.channelMode = channel;
|
||||||
|
// Persist channel preference to settings.json
|
||||||
|
fetch('/api/lm/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ update_channel: channel })
|
||||||
|
}).then(r => {
|
||||||
|
if (!r.ok) console.warn('Failed to persist update channel:', r.status);
|
||||||
|
}).catch(e => console.warn('Failed to persist update channel:', e));
|
||||||
|
await this.checkForUpdates({ force: true });
|
||||||
|
this.updateModalContent();
|
||||||
|
this.updateChannelUI();
|
||||||
|
this._showSwitchCompleteMessage(data.new_version);
|
||||||
|
this.progressKeepVisible = true;
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || translate('update.channelSwitch.failed'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Channel switch failed:', error);
|
||||||
|
this.updateProgress(0, translate('update.channelSwitch.failed'));
|
||||||
|
} finally {
|
||||||
|
if (this.progressKeepVisible) {
|
||||||
|
this.isUpdating = false;
|
||||||
|
this.progressKeepVisible = false;
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.showUpdateProgress(false);
|
||||||
|
this.isUpdating = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChannelUI() {
|
||||||
|
const releaseBtn = document.getElementById('channelRelease');
|
||||||
|
const nightlyBtn = document.getElementById('channelNightly');
|
||||||
|
|
||||||
|
if (releaseBtn) {
|
||||||
|
releaseBtn.classList.toggle('active', this.channelMode === 'release');
|
||||||
|
}
|
||||||
|
if (nightlyBtn) {
|
||||||
|
nightlyBtn.classList.toggle('active', this.channelMode === 'nightly');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolveChannelFromSettings() {
|
||||||
|
const stored = state?.global?.settings?.update_channel;
|
||||||
|
if (stored === 'nightly' || stored === 'release') {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
if (!this.hasGit) {
|
||||||
|
return 'release';
|
||||||
|
}
|
||||||
|
if (this.gitInfo?.branch === 'detached') {
|
||||||
|
return 'release';
|
||||||
|
}
|
||||||
|
return 'nightly';
|
||||||
|
}
|
||||||
|
|
||||||
|
async _confirmChannelSwitch(titleKey, messageKey) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const title = translate(titleKey);
|
||||||
|
const message = translate(messageKey);
|
||||||
|
const cancelText = translate('common.cancel');
|
||||||
|
const confirmText = translate('common.confirm');
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'channel-switch-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="channel-switch-dialog">
|
||||||
|
<h3>${title}</h3>
|
||||||
|
<p>${message}</p>
|
||||||
|
<div class="channel-switch-actions">
|
||||||
|
<button class="secondary-btn channel-switch-cancel">${cancelText}</button>
|
||||||
|
<button class="primary-btn channel-switch-confirm">${confirmText}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dismiss = (result) => {
|
||||||
|
document.removeEventListener('keydown', onKeydown);
|
||||||
|
overlay.remove();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeydown = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
dismiss(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeydown, { capture: true });
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) {
|
||||||
|
dismiss(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.querySelector('.channel-switch-cancel').addEventListener('click', () => {
|
||||||
|
dismiss(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.querySelector('.channel-switch-confirm').addEventListener('click', () => {
|
||||||
|
dismiss(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setupNotificationCenter() {
|
setupNotificationCenter() {
|
||||||
@@ -355,6 +497,18 @@ export class UpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkForUpdates({ force = false } = {}) {
|
async checkForUpdates({ force = false } = {}) {
|
||||||
|
let needsMigration = false;
|
||||||
|
if (this.channelMode === null) {
|
||||||
|
const stored = state?.global?.settings?.update_channel;
|
||||||
|
if (stored === 'nightly' || stored === 'release') {
|
||||||
|
this.channelMode = stored;
|
||||||
|
} else if (!this.hasGit) {
|
||||||
|
this.channelMode = 'release';
|
||||||
|
needsMigration = true;
|
||||||
|
}
|
||||||
|
// hasGit=true with no stored value: wait for gitInfo.branch
|
||||||
|
}
|
||||||
|
|
||||||
if (!force && !this.updateNotificationsEnabled) {
|
if (!force && !this.updateNotificationsEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -373,7 +527,8 @@ export class UpdateService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Call backend API to check for updates with nightly flag
|
// Call backend API to check for updates with nightly flag
|
||||||
const response = await fetch(`/api/lm/check-updates?nightly=${this.nightlyMode}`);
|
const nightly = (this.channelMode ?? (this.hasGit ? 'nightly' : 'release')) === 'nightly';
|
||||||
|
const response = await fetch(`/api/lm/check-updates?nightly=${nightly}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
@@ -381,17 +536,35 @@ export class UpdateService {
|
|||||||
this.latestVersion = data.latest_version || "v0.0.0";
|
this.latestVersion = data.latest_version || "v0.0.0";
|
||||||
this.updateInfo = data;
|
this.updateInfo = data;
|
||||||
this.gitInfo = data.git_info || this.gitInfo;
|
this.gitInfo = data.git_info || this.gitInfo;
|
||||||
|
this.hasGit = data.has_git || false;
|
||||||
|
|
||||||
// Explicitly set update availability based on version comparison
|
if (needsMigration || this.channelMode === null) {
|
||||||
this.updateAvailable = this.isNewerVersion(this.latestVersion, this.currentVersion);
|
this.channelMode = this._resolveChannelFromSettings();
|
||||||
|
if (state?.global?.settings) {
|
||||||
|
state.global.settings.update_channel = this.channelMode;
|
||||||
|
}
|
||||||
|
fetch('/api/lm/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ update_channel: this.channelMode })
|
||||||
|
}).then(r => {
|
||||||
|
if (!r.ok) console.warn('Failed to persist update channel:', r.status);
|
||||||
|
}).catch(e => console.warn('Failed to persist update channel:', e));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateAvailable = data.update_available;
|
||||||
|
|
||||||
|
// Nightly channel: surface the update badge at most once per calendar day.
|
||||||
|
if (this.updateAvailable && this.channelMode === 'nightly' && this.nightlyNotifyDate !== this._getTodayKey()) {
|
||||||
|
this._markNightlyNotified();
|
||||||
|
}
|
||||||
|
|
||||||
// Update last check time
|
|
||||||
this.lastCheckTime = now;
|
this.lastCheckTime = now;
|
||||||
setStorageItem('last_update_check', now.toString());
|
setStorageItem('last_update_check', now.toString());
|
||||||
|
|
||||||
// Update UI
|
|
||||||
this.updateBadgeVisibility();
|
this.updateBadgeVisibility();
|
||||||
this.updateModalContent();
|
this.updateModalContent();
|
||||||
|
this.updateChannelUI();
|
||||||
|
|
||||||
console.log("Update check complete:", {
|
console.log("Update check complete:", {
|
||||||
currentVersion: this.currentVersion,
|
currentVersion: this.currentVersion,
|
||||||
@@ -436,6 +609,28 @@ export class UpdateService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getTodayKey() {
|
||||||
|
const now = new Date();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
|
return `${now.getFullYear()}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isNightlyBadgeAllowed() {
|
||||||
|
if (this.channelMode !== 'nightly') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Keep the badge visible for the rest of the session once shown, but do
|
||||||
|
// not show it again on later sessions within the same calendar day.
|
||||||
|
return this.nightlyNotifyDate !== this._getTodayKey() || this.nightlyBadgeShown;
|
||||||
|
}
|
||||||
|
|
||||||
|
_markNightlyNotified() {
|
||||||
|
this.nightlyNotifyDate = this._getTodayKey();
|
||||||
|
this.nightlyBadgeShown = true;
|
||||||
|
setStorageItem('nightly_notify_date', this.nightlyNotifyDate);
|
||||||
|
}
|
||||||
|
|
||||||
updateBadgeVisibility() {
|
updateBadgeVisibility() {
|
||||||
const updateToggle = document.querySelector('.update-toggle');
|
const updateToggle = document.querySelector('.update-toggle');
|
||||||
const updateBadge = document.querySelector('.update-toggle .update-badge');
|
const updateBadge = document.querySelector('.update-toggle .update-badge');
|
||||||
@@ -443,9 +638,12 @@ export class UpdateService {
|
|||||||
? bannerService.getUnreadBannerCount()
|
? bannerService.getUnreadBannerCount()
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
// Force updating badges visibility based on current state
|
||||||
|
const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable && this._isNightlyBadgeAllowed();
|
||||||
|
|
||||||
if (updateToggle) {
|
if (updateToggle) {
|
||||||
let tooltipKey = 'header.actions.notifications';
|
let tooltipKey = 'header.actions.notifications';
|
||||||
if (this.updateNotificationsEnabled && this.updateAvailable) {
|
if (shouldShowUpdate) {
|
||||||
tooltipKey = 'update.updateAvailable';
|
tooltipKey = 'update.updateAvailable';
|
||||||
} else if (unreadBanners > 0) {
|
} else if (unreadBanners > 0) {
|
||||||
tooltipKey = 'update.tabs.messages';
|
tooltipKey = 'update.tabs.messages';
|
||||||
@@ -453,8 +651,6 @@ export class UpdateService {
|
|||||||
updateToggle.title = translate(tooltipKey);
|
updateToggle.title = translate(tooltipKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force updating badges visibility based on current state
|
|
||||||
const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable;
|
|
||||||
const shouldShow = shouldShowUpdate || unreadBanners > 0;
|
const shouldShow = shouldShowUpdate || unreadBanners > 0;
|
||||||
|
|
||||||
if (updateBadge) {
|
if (updateBadge) {
|
||||||
@@ -482,9 +678,32 @@ export class UpdateService {
|
|||||||
|
|
||||||
if (currentVersionEl) currentVersionEl.textContent = this.currentVersion;
|
if (currentVersionEl) currentVersionEl.textContent = this.currentVersion;
|
||||||
|
|
||||||
|
const newVersionLabel = modal.querySelector('.new-version .label');
|
||||||
|
if (newVersionLabel) {
|
||||||
|
newVersionLabel.textContent = (this.updateInfo?.nightly)
|
||||||
|
? `${translate('update.latestMain')}:`
|
||||||
|
: `${translate('update.newVersion')}:`;
|
||||||
|
}
|
||||||
|
|
||||||
if (newVersionEl) {
|
if (newVersionEl) {
|
||||||
|
if (this.updateInfo?.nightly) {
|
||||||
|
const behind = this.updateInfo.behind_by || 0;
|
||||||
|
const remoteHash = this.latestVersion.replace('main-', '');
|
||||||
|
const localHash = this.gitInfo.short_hash || '';
|
||||||
|
const date = this.updateInfo.commit_date || '';
|
||||||
|
const datePart = date ? ` · ${date}` : '';
|
||||||
|
|
||||||
|
if (behind > 0) {
|
||||||
|
newVersionEl.textContent = `${behind} commit${behind !== 1 ? 's' : ''} behind main (${remoteHash}${datePart})`;
|
||||||
|
} else if (localHash !== remoteHash) {
|
||||||
|
newVersionEl.textContent = `Behind main (${remoteHash}${datePart})`;
|
||||||
|
} else {
|
||||||
|
newVersionEl.textContent = `Up to date (${remoteHash}${datePart})`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
newVersionEl.textContent = this.latestVersion;
|
newVersionEl.textContent = this.latestVersion;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update update button state
|
// Update update button state
|
||||||
const updateBtn = modal.querySelector('#updateBtn');
|
const updateBtn = modal.querySelector('#updateBtn');
|
||||||
@@ -599,10 +818,14 @@ export class UpdateService {
|
|||||||
// Update GitHub link to point to the specific release if available
|
// Update GitHub link to point to the specific release if available
|
||||||
const githubLink = modal.querySelector('.update-link');
|
const githubLink = modal.querySelector('.update-link');
|
||||||
if (githubLink && this.latestVersion) {
|
if (githubLink && this.latestVersion) {
|
||||||
|
if (this.updateInfo?.nightly) {
|
||||||
|
githubLink.href = 'https://github.com/willmiao/ComfyUI-Lora-Manager/commits/main';
|
||||||
|
} else {
|
||||||
const versionTag = this.latestVersion.replace(/^v/, '');
|
const versionTag = this.latestVersion.replace(/^v/, '');
|
||||||
githubLink.href = `https://github.com/willmiao/ComfyUI-Lora-Manager/releases/tag/v${versionTag}`;
|
githubLink.href = `https://github.com/willmiao/ComfyUI-Lora-Manager/releases/tag/v${versionTag}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async performUpdate() {
|
async performUpdate() {
|
||||||
if (!this.updateAvailable || this.isUpdating) {
|
if (!this.updateAvailable || this.isUpdating) {
|
||||||
@@ -623,7 +846,7 @@ export class UpdateService {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
nightly: this.nightlyMode
|
nightly: this.channelMode === 'nightly'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -699,6 +922,25 @@ export class UpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_showSwitchCompleteMessage(version) {
|
||||||
|
this.showUpdateProgress(true);
|
||||||
|
this.updateProgress(100, '');
|
||||||
|
const progressText = document.getElementById('updateProgressText');
|
||||||
|
if (progressText) {
|
||||||
|
progressText.innerHTML = `
|
||||||
|
<div style="text-align: center; color: var(--lora-success);">
|
||||||
|
<i class="fas fa-check-circle" style="margin-right: 8px;"></i>
|
||||||
|
${translate('update.completion.successMessage', { version })}
|
||||||
|
<br><br>
|
||||||
|
<div style="opacity: 0.95; color: var(--lora-error); font-size: 1em;">
|
||||||
|
${translate('update.completion.restartMessage')}<br>
|
||||||
|
${translate('update.completion.reloadMessage')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
showUpdateCompleteMessage(newVersion) {
|
showUpdateCompleteMessage(newVersion) {
|
||||||
const modal = document.getElementById('updateModal');
|
const modal = document.getElementById('updateModal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
@@ -771,6 +1013,7 @@ export class UpdateService {
|
|||||||
|
|
||||||
// Update the modal content immediately with current data
|
// Update the modal content immediately with current data
|
||||||
this.updateModalContent();
|
this.updateModalContent();
|
||||||
|
this.updateChannelUI();
|
||||||
this.renderRecentBanners();
|
this.renderRecentBanners();
|
||||||
|
|
||||||
// Show the modal with current data
|
// Show the modal with current data
|
||||||
@@ -801,8 +1044,8 @@ export class UpdateService {
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
this.currentVersionInfo = data.version;
|
this.currentVersionInfo = data.version;
|
||||||
|
this.hasGit = data.has_git || false;
|
||||||
|
|
||||||
// Check if version matches stored version
|
|
||||||
this.versionMismatch = !isVersionMatch(this.currentVersionInfo);
|
this.versionMismatch = !isVersionMatch(this.currentVersionInfo);
|
||||||
|
|
||||||
if (this.versionMismatch) {
|
if (this.versionMismatch) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { translate } from '../../utils/i18nHelpers.js';
|
|||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||||
import { getStorageItem } from '../../utils/storageHelpers.js';
|
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||||
|
import { state } from '../../state/index.js';
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
constructor(importManager) {
|
constructor(importManager) {
|
||||||
@@ -125,11 +126,25 @@ export class DownloadManager {
|
|||||||
showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success');
|
showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal
|
|
||||||
modalManager.closeModal('importModal');
|
modalManager.closeModal('importModal');
|
||||||
|
|
||||||
// Refresh the recipe
|
if (isDownloadOnly && state.virtualScroller) {
|
||||||
window.recipeManager.loadRecipes(true);
|
const recipeId = this.importManager.recipeId;
|
||||||
|
try {
|
||||||
|
const detailRes = await fetch(`/api/lm/recipe/${encodeURIComponent(recipeId)}`);
|
||||||
|
if (detailRes.ok) {
|
||||||
|
const updated = await detailRes.json();
|
||||||
|
state.virtualScroller.updateSingleItem(updated.file_path, updated);
|
||||||
|
} else {
|
||||||
|
throw new Error(`API returned ${detailRes.status}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to update recipe card in-place, falling back to reload:', e);
|
||||||
|
await window.recipeManager.loadRecipes({ resetPage: true, preserveScroll: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.recipeManager.loadRecipes({ resetPage: true, preserveScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
language: 'en',
|
language: 'en',
|
||||||
show_only_sfw: false,
|
show_only_sfw: false,
|
||||||
enable_metadata_archive_db: false,
|
enable_metadata_archive_db: false,
|
||||||
|
enable_civarchive_api: true,
|
||||||
|
metadata_provider_order: 'civitai_archive_sqlite',
|
||||||
proxy_enabled: false,
|
proxy_enabled: false,
|
||||||
proxy_type: 'http',
|
proxy_type: 'http',
|
||||||
proxy_host: '',
|
proxy_host: '',
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ export const PATH_TEMPLATE_PLACEHOLDERS = [
|
|||||||
export const DEFAULT_PATH_TEMPLATES = {
|
export const DEFAULT_PATH_TEMPLATES = {
|
||||||
lora: '{base_model}/{first_tag}',
|
lora: '{base_model}/{first_tag}',
|
||||||
checkpoint: '{base_model}',
|
checkpoint: '{base_model}',
|
||||||
|
unet: '{base_model}',
|
||||||
embedding: '{first_tag}'
|
embedding: '{first_tag}'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -369,21 +370,24 @@ export function getMatureBlurThreshold(settings = {}) {
|
|||||||
export const NODE_TYPES = {
|
export const NODE_TYPES = {
|
||||||
LORA_LOADER: 1,
|
LORA_LOADER: 1,
|
||||||
LORA_STACKER: 2,
|
LORA_STACKER: 2,
|
||||||
WAN_VIDEO_LORA_SELECT: 3
|
WAN_VIDEO_LORA_SELECT: 3,
|
||||||
|
HOOK_LORA: 4
|
||||||
};
|
};
|
||||||
|
|
||||||
// Node type names to IDs mapping
|
// Node type names to IDs mapping
|
||||||
export const NODE_TYPE_NAMES = {
|
export const NODE_TYPE_NAMES = {
|
||||||
"Lora Loader (LoraManager)": NODE_TYPES.LORA_LOADER,
|
"Lora Loader (LoraManager)": NODE_TYPES.LORA_LOADER,
|
||||||
"Lora Stacker (LoraManager)": NODE_TYPES.LORA_STACKER,
|
"Lora Stacker (LoraManager)": NODE_TYPES.LORA_STACKER,
|
||||||
"WanVideo Lora Select (LoraManager)": NODE_TYPES.WAN_VIDEO_LORA_SELECT
|
"WanVideo Lora Select (LoraManager)": NODE_TYPES.WAN_VIDEO_LORA_SELECT,
|
||||||
|
"Create Hook LoRA (LoraManager)": NODE_TYPES.HOOK_LORA
|
||||||
};
|
};
|
||||||
|
|
||||||
// Node type icons
|
// Node type icons
|
||||||
export const NODE_TYPE_ICONS = {
|
export const NODE_TYPE_ICONS = {
|
||||||
[NODE_TYPES.LORA_LOADER]: "fas fa-l",
|
[NODE_TYPES.LORA_LOADER]: "fas fa-l",
|
||||||
[NODE_TYPES.LORA_STACKER]: "fas fa-s",
|
[NODE_TYPES.LORA_STACKER]: "fas fa-s",
|
||||||
[NODE_TYPES.WAN_VIDEO_LORA_SELECT]: "fas fa-w"
|
[NODE_TYPES.WAN_VIDEO_LORA_SELECT]: "fas fa-w",
|
||||||
|
[NODE_TYPES.HOOK_LORA]: "fas fa-h"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default ComfyUI node color when bgcolor is null
|
// Default ComfyUI node color when bgcolor is null
|
||||||
|
|||||||
@@ -141,6 +141,20 @@ const PARAM_TO_WIDGET_CANDIDATES = {
|
|||||||
scheduler: ['scheduler'],
|
scheduler: ['scheduler'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Node-type-specific widget name overrides.
|
||||||
|
// Keys are ComfyUI node class names (e.g. "GlobalSeed //Inspire").
|
||||||
|
// Values are partial PARAM_TO_WIDGET_CANDIDATES maps; the per-node candidates
|
||||||
|
// are tried *before* the global ones. Only the params listed here are
|
||||||
|
// overridden — every other param still uses the global candidates.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const NODE_TYPE_WIDGET_OVERRIDES = {
|
||||||
|
// Inspire Pack — Global Seed node stores the seed in a widget named "value"
|
||||||
|
'GlobalSeed //Inspire': {
|
||||||
|
seed: ['value'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Parse a combined sampler+scheduler value (space-separated or underscore)
|
// Parse a combined sampler+scheduler value (space-separated or underscore)
|
||||||
// e.g., "Euler a Karras", "DPM++ 2M beta", "er_sde_beta"
|
// e.g., "Euler a Karras", "DPM++ 2M beta", "er_sde_beta"
|
||||||
@@ -235,7 +249,7 @@ function resolveSamplerScheduler(rawValue) {
|
|||||||
// Find which gen params can be sent to a given node, matching by widget names
|
// Find which gen params can be sent to a given node, matching by widget names
|
||||||
// Returns array of { widgetName, value } objects
|
// Returns array of { widgetName, value } objects
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
function findMatchingWidgets(nodeWidgetNames, resolvedParams, nodeType) {
|
||||||
if (!nodeWidgetNames || !Array.isArray(nodeWidgetNames) || nodeWidgetNames.length === 0) {
|
if (!nodeWidgetNames || !Array.isArray(nodeWidgetNames) || nodeWidgetNames.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -243,6 +257,26 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
|||||||
const widgetSet = new Set(nodeWidgetNames.map(w => String(w).toLowerCase()));
|
const widgetSet = new Set(nodeWidgetNames.map(w => String(w).toLowerCase()));
|
||||||
const updates = [];
|
const updates = [];
|
||||||
|
|
||||||
|
// Resolve node-type-specific overrides (if any)
|
||||||
|
const typeOverrides =
|
||||||
|
nodeType && typeof nodeType === 'string'
|
||||||
|
? (NODE_TYPE_WIDGET_OVERRIDES[nodeType] || {})
|
||||||
|
: {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the effective candidate list for a parameter:
|
||||||
|
* type-specific overrides (if any) come first, then the global candidates.
|
||||||
|
*/
|
||||||
|
function getCandidates(key) {
|
||||||
|
const global = PARAM_TO_WIDGET_CANDIDATES[key] || [key];
|
||||||
|
const extra = typeOverrides[key];
|
||||||
|
if (extra && Array.isArray(extra) && extra.length > 0) {
|
||||||
|
// Prepend type-specific candidates; keep global as fallback
|
||||||
|
return [...extra, ...global];
|
||||||
|
}
|
||||||
|
return global;
|
||||||
|
}
|
||||||
|
|
||||||
// Simple numeric/string params: seed, steps, cfg
|
// Simple numeric/string params: seed, steps, cfg
|
||||||
const simpleParams = [
|
const simpleParams = [
|
||||||
{ key: 'seed', value: resolvedParams.seed },
|
{ key: 'seed', value: resolvedParams.seed },
|
||||||
@@ -251,10 +285,10 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
|||||||
];
|
];
|
||||||
for (const { key, value } of simpleParams) {
|
for (const { key, value } of simpleParams) {
|
||||||
if (value === undefined || value === null || value === '') continue;
|
if (value === undefined || value === null || value === '') continue;
|
||||||
const candidates = PARAM_TO_WIDGET_CANDIDATES[key] || [key];
|
const candidates = getCandidates(key);
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (widgetSet.has(candidate.toLowerCase())) {
|
if (widgetSet.has(candidate.toLowerCase())) {
|
||||||
updates.push({ widgetName: candidate, value: String(value) });
|
updates.push({ widgetName: candidate, value });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,7 +296,7 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
|||||||
|
|
||||||
// Sampler
|
// Sampler
|
||||||
if (resolvedParams.sampler) {
|
if (resolvedParams.sampler) {
|
||||||
const candidates = PARAM_TO_WIDGET_CANDIDATES.sampler;
|
const candidates = getCandidates('sampler');
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (widgetSet.has(candidate.toLowerCase())) {
|
if (widgetSet.has(candidate.toLowerCase())) {
|
||||||
updates.push({ widgetName: candidate, value: resolvedParams.sampler });
|
updates.push({ widgetName: candidate, value: resolvedParams.sampler });
|
||||||
@@ -273,7 +307,7 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
|||||||
|
|
||||||
// Scheduler
|
// Scheduler
|
||||||
if (resolvedParams.scheduler) {
|
if (resolvedParams.scheduler) {
|
||||||
const candidates = PARAM_TO_WIDGET_CANDIDATES.scheduler;
|
const candidates = getCandidates('scheduler');
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (widgetSet.has(candidate.toLowerCase())) {
|
if (widgetSet.has(candidate.toLowerCase())) {
|
||||||
updates.push({ widgetName: candidate, value: resolvedParams.scheduler });
|
updates.push({ widgetName: candidate, value: resolvedParams.scheduler });
|
||||||
@@ -290,6 +324,7 @@ export {
|
|||||||
SCHEDULER_SUFFIXES,
|
SCHEDULER_SUFFIXES,
|
||||||
SCHEDULER_ONLY_VALUES,
|
SCHEDULER_ONLY_VALUES,
|
||||||
PARAM_TO_WIDGET_CANDIDATES,
|
PARAM_TO_WIDGET_CANDIDATES,
|
||||||
|
NODE_TYPE_WIDGET_OVERRIDES,
|
||||||
parseCombinedSamplerName,
|
parseCombinedSamplerName,
|
||||||
resolveSamplerScheduler,
|
resolveSamplerScheduler,
|
||||||
findMatchingWidgets,
|
findMatchingWidgets,
|
||||||
|
|||||||
@@ -134,7 +134,10 @@ export async function copyToClipboard(text, successMessage = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function showToast(key, params = {}, type = 'info', fallback = null) {
|
export function showToast(key, params = {}, type = 'info', fallback = null) {
|
||||||
const message = translate(key, params, fallback);
|
// Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
|
||||||
|
// to avoid spurious "Translation key not found" warnings from i18next
|
||||||
|
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
|
||||||
|
const message = isPlainMessage ? key : translate(key, params, fallback);
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast toast-${type}`;
|
toast.className = `toast toast-${type}`;
|
||||||
toast.textContent = message;
|
toast.textContent = message;
|
||||||
@@ -605,7 +608,7 @@ function isNodeEnabled(node) {
|
|||||||
if (!node) {
|
if (!node) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// ComfyUI node mode: 0 = Normal/Enabled, others = Always/Never/OnEvent
|
// ComfyUI node mode (LGraphEventMode): 0 = Always, 2 = Never, 4 = Bypass
|
||||||
return node.mode === undefined || node.mode === 0;
|
return node.mode === undefined || node.mode === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,7 +659,7 @@ async function ensureRelativeModelPath(modelPath, collectionType) {
|
|||||||
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
|
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
|
||||||
* @returns {Promise<boolean>} - Whether the operation was successful
|
* @returns {Promise<boolean>} - Whether the operation was successful
|
||||||
*/
|
*/
|
||||||
export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntaxType = 'lora') {
|
export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntaxType = 'lora', onComplete = null) {
|
||||||
const registry = await fetchWorkflowRegistry();
|
const registry = await fetchWorkflowRegistry();
|
||||||
if (!registry) {
|
if (!registry) {
|
||||||
return false;
|
return false;
|
||||||
@@ -681,7 +684,9 @@ export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntax
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nodeKeys.length === 1) {
|
if (nodeKeys.length === 1) {
|
||||||
return await sendLoraToNodes([nodeKeys[0]], loraNodes, loraSyntax, replaceMode, syntaxType);
|
const result = await sendLoraToNodes([nodeKeys[0]], loraNodes, loraSyntax, replaceMode, syntaxType);
|
||||||
|
if (result && typeof onComplete === 'function') onComplete();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionType =
|
const actionType =
|
||||||
@@ -695,8 +700,11 @@ export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntax
|
|||||||
showNodeSelector(loraNodes, {
|
showNodeSelector(loraNodes, {
|
||||||
actionType,
|
actionType,
|
||||||
actionMode,
|
actionMode,
|
||||||
onSend: (selectedNodeIds) =>
|
onSend: async (selectedNodeIds) => {
|
||||||
sendLoraToNodes(selectedNodeIds, loraNodes, loraSyntax, replaceMode, syntaxType),
|
const result = await sendLoraToNodes(selectedNodeIds, loraNodes, loraSyntax, replaceMode, syntaxType);
|
||||||
|
if (result && typeof onComplete === 'function') onComplete();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -967,7 +975,7 @@ async function sendTextToNodes(nodeIds, nodesMap, text, mode, messages = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendEmbeddingToWorkflow(embeddingCode) {
|
export async function sendEmbeddingToWorkflow(embeddingCode, onComplete = null) {
|
||||||
const registry = await fetchWorkflowRegistry();
|
const registry = await fetchWorkflowRegistry();
|
||||||
if (!registry) {
|
if (!registry) {
|
||||||
return false;
|
return false;
|
||||||
@@ -995,8 +1003,11 @@ export async function sendEmbeddingToWorkflow(embeddingCode) {
|
|||||||
missingTargetMessage: translate('uiHelpers.workflow.noTargetNodeSelected', {}, 'No target node selected'),
|
missingTargetMessage: translate('uiHelpers.workflow.noTargetNodeSelected', {}, 'No target node selected'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = (selectedNodeIds) =>
|
const handleSend = async (selectedNodeIds) => {
|
||||||
sendTextToNodes(selectedNodeIds, textNodes, embeddingCode, 'append', messages);
|
const result = await sendTextToNodes(selectedNodeIds, textNodes, embeddingCode, 'append', messages);
|
||||||
|
if (result && typeof onComplete === 'function') onComplete();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
if (nodeKeys.length === 1) {
|
if (nodeKeys.length === 1) {
|
||||||
return await handleSend([nodeKeys[0]]);
|
return await handleSend([nodeKeys[0]]);
|
||||||
@@ -1136,8 +1147,8 @@ export async function sendGenParamsToWorkflow(genParams) {
|
|||||||
const node = targetNodes[nodeKey];
|
const node = targetNodes[nodeKey];
|
||||||
if (!node) continue;
|
if (!node) continue;
|
||||||
|
|
||||||
const widgetNames = node.widget_names || [];
|
const widgetNames = getWidgetNames(node);
|
||||||
const updates = findMatchingWidgets(widgetNames, raw);
|
const updates = findMatchingWidgets(widgetNames, raw, node.type_name);
|
||||||
|
|
||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
showToast(`Node "${node.title || node.type}" has no matching widgets for these parameters`, {}, 'warning');
|
showToast(`Node "${node.title || node.type}" has no matching widgets for these parameters`, {}, 'warning');
|
||||||
|
|||||||
@@ -48,6 +48,11 @@
|
|||||||
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
|
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if page_id != 'recipes' %}
|
||||||
|
<optgroup label="{{ t('loras.controls.sort.random', default='Random') }}">
|
||||||
|
<option value="random">{{ t('loras.controls.sort.randomAction', default='Randomize (shuffle)') }}</option>
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
{% if page_id == 'recipes' %}
|
{% if page_id == 'recipes' %}
|
||||||
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
||||||
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
||||||
|
|||||||
@@ -251,10 +251,15 @@
|
|||||||
<button class="tag-logic-option" data-value="all" title="{{ t('header.filter.tagLogicAll') }}">{{ t('header.filter.all') }}</button>
|
<button class="tag-logic-option" data-value="all" title="{{ t('header.filter.tagLogicAll') }}">{{ t('header.filter.all') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="text" id="modelTagsSearchInput" class="filter-search-input"
|
||||||
|
placeholder="{{ t('header.filter.tagSearchPlaceholder') }}" autocomplete="off">
|
||||||
<div class="filter-tags" id="modelTagsFilter">
|
<div class="filter-tags" id="modelTagsFilter">
|
||||||
<!-- Top tags will be dynamically inserted here -->
|
<!-- Top tags will be dynamically inserted here -->
|
||||||
<div class="tags-loading">{{ t('common.status.loading') }}</div>
|
<div class="tags-loading">{{ t('common.status.loading') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="modelTagsEmptyState" class="filter-empty-state" hidden>
|
||||||
|
{{ t('header.filter.noTagMatches') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if current_page == 'loras' or current_page == 'checkpoints' %}
|
{% if current_page == 'loras' or current_page == 'checkpoints' %}
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
|
|||||||
@@ -144,6 +144,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-subsection">
|
||||||
|
<div class="settings-subsection-header">
|
||||||
|
<h4>{{ t('settings.sections.downloads') }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label>
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i>
|
||||||
|
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Aria2-Download-Backend-(Experimental)" target="_blank" rel="noopener" aria-label="{{ t('settings.aria2HelpLink') }}" title="{{ t('settings.aria2HelpLink') }}">
|
||||||
|
<i class="fas fa-question-circle" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control select-control">
|
||||||
|
<select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')">
|
||||||
|
<option value="python">{{ t('settings.downloadBackend.options.python') }}</option>
|
||||||
|
<option value="aria2">{{ t('settings.downloadBackend.options.aria2') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item" id="aria2PathSetting" style="display: none;">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="aria2cPath">{{ t('settings.aria2cPath.label') }}</label>
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aria2cPath.help') }}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<div class="text-input-wrapper">
|
||||||
|
<input type="text"
|
||||||
|
id="aria2cPath"
|
||||||
|
placeholder="{{ t('settings.aria2cPath.placeholder') }}"
|
||||||
|
onblur="settingsManager.saveInputSetting('aria2cPath', 'aria2c_path')"
|
||||||
|
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- AI Provider Configuration (BYOK) -->
|
<!-- AI Provider Configuration (BYOK) -->
|
||||||
<div class="settings-subsection">
|
<div class="settings-subsection">
|
||||||
<div class="settings-subsection-header">
|
<div class="settings-subsection-header">
|
||||||
@@ -250,46 +290,6 @@
|
|||||||
{{ provider_models_json | safe }}
|
{{ provider_models_json | safe }}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="settings-subsection">
|
|
||||||
<div class="settings-subsection-header">
|
|
||||||
<h4>{{ t('settings.sections.downloads') }}</h4>
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-info">
|
|
||||||
<label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label>
|
|
||||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i>
|
|
||||||
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Aria2-Download-Backend-(Experimental)" target="_blank" rel="noopener" aria-label="{{ t('settings.aria2HelpLink') }}" title="{{ t('settings.aria2HelpLink') }}">
|
|
||||||
<i class="fas fa-question-circle" aria-hidden="true"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control select-control">
|
|
||||||
<select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')">
|
|
||||||
<option value="python">{{ t('settings.downloadBackend.options.python') }}</option>
|
|
||||||
<option value="aria2">{{ t('settings.downloadBackend.options.aria2') }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-item" id="aria2PathSetting" style="display: none;">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-info">
|
|
||||||
<label for="aria2cPath">{{ t('settings.aria2cPath.label') }}</label>
|
|
||||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aria2cPath.help') }}"></i>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control">
|
|
||||||
<div class="text-input-wrapper">
|
|
||||||
<input type="text"
|
|
||||||
id="aria2cPath"
|
|
||||||
placeholder="{{ t('settings.aria2cPath.placeholder') }}"
|
|
||||||
onblur="settingsManager.saveInputSetting('aria2cPath', 'aria2c_path')"
|
|
||||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Backup -->
|
<!-- Backup -->
|
||||||
<div class="settings-subsection">
|
<div class="settings-subsection">
|
||||||
<div class="settings-subsection-header">
|
<div class="settings-subsection-header">
|
||||||
@@ -1401,7 +1401,26 @@
|
|||||||
<div class="settings-input-error-message" id="metadataRefreshSkipPathsError"></div>
|
<div class="settings-input-error-message" id="metadataRefreshSkipPathsError"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Metadata Archive -->
|
<!-- CivArchive API provider toggle -->
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="enableCivarchiveApi">
|
||||||
|
{{ t('settings.metadataArchive.enableCivarchiveApi') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.metadataArchive.enableCivarchiveApiHelp') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="enableCivarchiveApi"
|
||||||
|
onchange="settingsManager.saveToggleSetting('enableCivarchiveApi', 'enable_civarchive_api')">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata Archive DB -->
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
@@ -1444,6 +1463,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata provider fallback order -->
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="metadataProviderOrder">
|
||||||
|
{{ t('settings.metadataArchive.providerOrder') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.metadataArchive.providerOrderHelp') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control select-control">
|
||||||
|
<select id="metadataProviderOrder" onchange="settingsManager.saveSelectSetting('metadataProviderOrder', 'metadata_provider_order')">
|
||||||
|
<option value="civitai_archive_sqlite">{{ t('settings.metadataArchive.providerOrderCivitaiArchiveSqlite') }}</option>
|
||||||
|
<option value="civitai_sqlite_archive">{{ t('settings.metadataArchive.providerOrderCivitaiSqliteArchive') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,20 @@
|
|||||||
<div class="notification-panels">
|
<div class="notification-panels">
|
||||||
<div class="notification-panel active" id="updatesPanel" role="tabpanel" aria-labelledby="updatesTab" aria-hidden="false" tabindex="0" data-notification-panel="updates">
|
<div class="notification-panel active" id="updatesPanel" role="tabpanel" aria-labelledby="updatesTab" aria-hidden="false" tabindex="0" data-notification-panel="updates">
|
||||||
<div class="update-content">
|
<div class="update-content">
|
||||||
|
|
||||||
|
<!-- Channel Selector -->
|
||||||
|
<div class="update-channels" id="updateChannels">
|
||||||
|
<div class="channels-label">{{ t('update.channel') }}</div>
|
||||||
|
<div class="channel-toggle">
|
||||||
|
<button type="button" class="channel-btn" data-channel="release" id="channelRelease">
|
||||||
|
<i class="fas fa-tag"></i> {{ t('update.channels.release') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="channel-btn" data-channel="nightly" id="channelNightly">
|
||||||
|
<i class="fas fa-moon"></i> {{ t('update.channels.nightly') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="update-info">
|
<div class="update-info">
|
||||||
<div class="version-info">
|
<div class="version-info">
|
||||||
<div class="current-version">
|
<div class="current-version">
|
||||||
|
|||||||
@@ -823,3 +823,73 @@ def test_apply_library_settings_ignores_extra_lora_path_overlapping_primary_root
|
|||||||
"same lora folder" in record.message.lower()
|
"same lora folder" in record.message.lower()
|
||||||
for record in caplog.records
|
for record in caplog.records
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_paths_removes_stale_empty_default_when_comfyui_exists(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path,
|
||||||
|
):
|
||||||
|
"""When an empty-shell 'default' library coexists with 'comfyui', the
|
||||||
|
stale 'default' entry should be removed and 'comfyui' activated."""
|
||||||
|
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
class FakeSettingsService:
|
||||||
|
def __init__(self):
|
||||||
|
# Replicate the user's settings.json: empty default + populated comfyui
|
||||||
|
self.libraries = {
|
||||||
|
"default": {
|
||||||
|
"folder_paths": {},
|
||||||
|
"extra_folder_paths": {},
|
||||||
|
"default_lora_root": "",
|
||||||
|
"default_checkpoint_root": "",
|
||||||
|
"default_unet_root": "",
|
||||||
|
"default_embedding_root": "",
|
||||||
|
"recipes_path": "",
|
||||||
|
},
|
||||||
|
"comfyui": {
|
||||||
|
"folder_paths": {
|
||||||
|
key: list(value) for key, value in folder_paths.items()
|
||||||
|
},
|
||||||
|
"default_lora_root": folder_paths["loras"][0],
|
||||||
|
"default_checkpoint_root": folder_paths["checkpoints"][0],
|
||||||
|
"default_embedding_root": folder_paths["embeddings"][0],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# No active_library key — get_active_library_name() falls back to
|
||||||
|
# dict order, returning "default".
|
||||||
|
self.active_library = "default"
|
||||||
|
self.delete_calls: list[str] = []
|
||||||
|
self.upsert_calls: list[tuple[str, dict]] = []
|
||||||
|
|
||||||
|
def get_libraries(self):
|
||||||
|
return dict(self.libraries)
|
||||||
|
|
||||||
|
def delete_library(self, name: str):
|
||||||
|
self.delete_calls.append(name)
|
||||||
|
self.libraries.pop(name, None)
|
||||||
|
|
||||||
|
def rename_library(self, *_):
|
||||||
|
raise AssertionError("rename_library should not be invoked")
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self.active_library
|
||||||
|
|
||||||
|
def upsert_library(self, name: str, **payload):
|
||||||
|
self.upsert_calls.append((name, payload))
|
||||||
|
self.libraries[name] = {**payload}
|
||||||
|
if payload.get("activate"):
|
||||||
|
self.active_library = name
|
||||||
|
|
||||||
|
fake_settings = FakeSettingsService()
|
||||||
|
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||||
|
|
||||||
|
config_module.Config()
|
||||||
|
|
||||||
|
assert fake_settings.delete_calls == ["default"]
|
||||||
|
assert "default" not in fake_settings.libraries
|
||||||
|
assert set(fake_settings.libraries.keys()) == {"comfyui"}
|
||||||
|
|
||||||
|
assert len(fake_settings.upsert_calls) == 1
|
||||||
|
name, payload = fake_settings.upsert_calls[0]
|
||||||
|
assert name == "comfyui"
|
||||||
|
assert payload["activate"] is True
|
||||||
|
assert fake_settings.active_library == "comfyui"
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ sys.modules['comfy.utils'] = comfy_mock.utils
|
|||||||
sys.modules['comfy.sd'] = comfy_mock.sd
|
sys.modules['comfy.sd'] = comfy_mock.sd
|
||||||
sys.modules['comfy.model_management'] = comfy_mock.model_management
|
sys.modules['comfy.model_management'] = comfy_mock.model_management
|
||||||
sys.modules['comfy.comfy_types'] = comfy_mock.comfy_types
|
sys.modules['comfy.comfy_types'] = comfy_mock.comfy_types
|
||||||
|
sys.modules['comfy.hooks'] = MockModule("comfy.hooks")
|
||||||
|
|
||||||
execution_mock = MockModule("execution")
|
execution_mock = MockModule("execution")
|
||||||
execution_mock.PromptExecutor = mock.MagicMock()
|
execution_mock.PromptExecutor = mock.MagicMock()
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ function renderControlsDom(pageKey) {
|
|||||||
<div id="baseModelEmptyState" hidden></div>
|
<div id="baseModelEmptyState" hidden></div>
|
||||||
<div id="filterPresets" class="filter-presets"></div>
|
<div id="filterPresets" class="filter-presets"></div>
|
||||||
<div id="modelTagsFilter" class="filter-tags"></div>
|
<div id="modelTagsFilter" class="filter-tags"></div>
|
||||||
|
<input id="modelTagsSearchInput" />
|
||||||
|
<div id="modelTagsEmptyState" hidden></div>
|
||||||
<button class="clear-filter"></button>
|
<button class="clear-filter"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
@@ -961,4 +963,198 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
|
|||||||
expect(stateModule.state.bulkMode).toBe(true);
|
expect(stateModule.state.bulkMode).toBe(true);
|
||||||
expect(pageState.duplicatesMode).toBe(true);
|
expect(pageState.duplicatesMode).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('tag search', () => {
|
||||||
|
it('fetches /search-tags when typing in the tag search input (debounced)', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const searchTagsUrls = [];
|
||||||
|
global.fetch = vi.fn((url) => {
|
||||||
|
if (url.includes('/search-tags')) {
|
||||||
|
searchTagsUrls.push(url);
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('/top-tags')) {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||||
|
}
|
||||||
|
if (url.includes('/base-models')) {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
|
||||||
|
}
|
||||||
|
if (url.includes('/model-types')) {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
renderControlsDom('loras');
|
||||||
|
const stateModule = await import('../../../static/js/state/index.js');
|
||||||
|
stateModule.initPageState('loras');
|
||||||
|
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||||
|
const manager = new FilterManager({ page: 'loras' });
|
||||||
|
|
||||||
|
// Open the panel so tags load
|
||||||
|
manager.toggleFilterPanel();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
const input = document.getElementById('modelTagsSearchInput');
|
||||||
|
input.value = 'ani';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
// Before debounce fires, no search-tags call yet
|
||||||
|
expect(searchTagsUrls.length).toBe(0);
|
||||||
|
|
||||||
|
// Advance past the 150ms debounce
|
||||||
|
vi.advanceTimersByTime(160);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(searchTagsUrls.length).toBe(1);
|
||||||
|
expect(searchTagsUrls[0]).toContain('/search-tags');
|
||||||
|
expect(searchTagsUrls[0]).toContain('q=ani');
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders selected-but-missing tags in a dedicated group at the top', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
global.fetch = vi.fn((url) => {
|
||||||
|
if (url.includes('/search-tags')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('/top-tags')) {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||||
|
}
|
||||||
|
if (url.includes('/base-models')) {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
|
||||||
|
}
|
||||||
|
if (url.includes('/model-types')) {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
renderControlsDom('loras');
|
||||||
|
const stateModule = await import('../../../static/js/state/index.js');
|
||||||
|
stateModule.initPageState('loras');
|
||||||
|
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||||
|
const manager = new FilterManager({ page: 'loras' });
|
||||||
|
|
||||||
|
// Pre-seed an active tag filter that won't appear in search results
|
||||||
|
manager.filters.tags = { 'my-custom-tag': 'include' };
|
||||||
|
|
||||||
|
// Open panel and let top-tags load (empty)
|
||||||
|
manager.toggleFilterPanel();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
// Type a search query
|
||||||
|
const input = document.getElementById('modelTagsSearchInput');
|
||||||
|
input.value = 'ani';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
vi.advanceTimersByTime(160);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
const container = document.getElementById('modelTagsFilter');
|
||||||
|
const extraTag = container.querySelector('.filter-tag.extra-tag');
|
||||||
|
expect(extraTag).not.toBeNull();
|
||||||
|
expect(extraTag.dataset.tag).toBe('my-custom-tag');
|
||||||
|
|
||||||
|
// The search result tag should also be present
|
||||||
|
const resultTag = container.querySelector('.filter-tag.tag-filter[data-tag="anime"]');
|
||||||
|
expect(resultTag).not.toBeNull();
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when search returns no matches and no selected tags', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
global.fetch = vi.fn((url) => {
|
||||||
|
if (url.includes('/search-tags')) {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||||
|
}
|
||||||
|
if (url.includes('/top-tags')) {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||||
|
}
|
||||||
|
if (url.includes('/base-models')) {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
|
||||||
|
}
|
||||||
|
if (url.includes('/model-types')) {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
renderControlsDom('loras');
|
||||||
|
const stateModule = await import('../../../static/js/state/index.js');
|
||||||
|
stateModule.initPageState('loras');
|
||||||
|
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||||
|
const manager = new FilterManager({ page: 'loras' });
|
||||||
|
|
||||||
|
manager.toggleFilterPanel();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
const input = document.getElementById('modelTagsSearchInput');
|
||||||
|
input.value = 'zzz';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
vi.advanceTimersByTime(160);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
const emptyState = document.getElementById('modelTagsEmptyState');
|
||||||
|
expect(emptyState.hidden).toBe(false);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads top tags when search input is cleared', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let topTagsCallCount = 0;
|
||||||
|
global.fetch = vi.fn((url) => {
|
||||||
|
if (url.includes('/search-tags')) {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }) });
|
||||||
|
}
|
||||||
|
if (url.includes('/top-tags')) {
|
||||||
|
topTagsCallCount++;
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||||
|
}
|
||||||
|
if (url.includes('/base-models')) {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
|
||||||
|
}
|
||||||
|
if (url.includes('/model-types')) {
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
renderControlsDom('loras');
|
||||||
|
const stateModule = await import('../../../static/js/state/index.js');
|
||||||
|
stateModule.initPageState('loras');
|
||||||
|
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||||
|
const manager = new FilterManager({ page: 'loras' });
|
||||||
|
|
||||||
|
manager.toggleFilterPanel();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
const callsAfterOpen = topTagsCallCount;
|
||||||
|
expect(callsAfterOpen).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Type, then clear
|
||||||
|
const input = document.getElementById('modelTagsSearchInput');
|
||||||
|
input.value = 'ani';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
vi.advanceTimersByTime(160);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
vi.advanceTimersByTime(160);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
// An additional top-tags call should have happened after clearing
|
||||||
|
expect(topTagsCallCount).toBeGreaterThan(callsAfterOpen);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const resetAndReloadMock = vi.fn();
|
||||||
|
const getModelApiClientMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||||
|
getModelApiClient: getModelApiClientMock,
|
||||||
|
resetAndReload: resetAndReloadMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
openCivitaiByMetadata: vi.fn(),
|
||||||
|
updatePanelPositions: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
|
||||||
|
downloadManager: { showDownloadModal: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
|
||||||
|
sidebarManager: {
|
||||||
|
setHostPageControls: vi.fn(),
|
||||||
|
initialize: vi.fn(async () => {}),
|
||||||
|
refresh: vi.fn(async () => {}),
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
isInitialized: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/alphabet/index.js', () => ({
|
||||||
|
createAlphabetBar: vi.fn(() => ({ destroy: vi.fn() })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/updateCheckHelpers.js', () => ({
|
||||||
|
performModelUpdateCheck: vi.fn(async () => ({ status: 'success', displayName: 'LoRA', records: [] })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
|
||||||
|
resetAndReloadMock.mockResolvedValue(undefined);
|
||||||
|
getModelApiClientMock.mockReturnValue({});
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, base_models: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete window.bulkManager;
|
||||||
|
delete window.modelDuplicatesManager;
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderControlsDom(pageKey) {
|
||||||
|
document.body.dataset.page = pageKey;
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="controls">
|
||||||
|
<div id="excludedViewBanner" class="excluded-view-banner hidden">
|
||||||
|
<button id="excludedViewBackBtn">Back</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<div class="control-group">
|
||||||
|
<select id="sortSelect">
|
||||||
|
<option value="name:asc">Name Asc</option>
|
||||||
|
<option value="name:desc">Name Desc</option>
|
||||||
|
<option value="random">Randomize (shuffle)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="control-group dropdown-group">
|
||||||
|
<button data-action="refresh" class="dropdown-main"></button>
|
||||||
|
<button class="dropdown-toggle"></button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<div class="dropdown-item" data-action="full-rebuild"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<button data-action="fetch"></button>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<button data-action="download"></button>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<button data-action="bulk"></button>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<button data-action="find-duplicates"></button>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<button id="favoriteFilterBtn" class="favorite-filter"></button>
|
||||||
|
</div>
|
||||||
|
<div class="control-group dropdown-group update-filter-group">
|
||||||
|
<button id="updateFilterBtn" class="dropdown-main update-filter" aria-busy="false">
|
||||||
|
<span>Updates</span>
|
||||||
|
</button>
|
||||||
|
<button id="updateFilterMenuToggle" class="dropdown-toggle"></button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<div id="checkUpdatesMenuItem" class="dropdown-item" data-action="check-updates">
|
||||||
|
<span>Check updates</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="customFilterIndicator" class="control-group hidden">
|
||||||
|
<div class="filter-active">
|
||||||
|
<span class="customFilterText" title=""></span>
|
||||||
|
<i class="fas fa-times-circle clear-filter"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="breadcrumbContainer"></div>
|
||||||
|
<div id="duplicatesBanner" style="display: none;"></div>
|
||||||
|
<div class="alphabet-bar-container"></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createControls() {
|
||||||
|
const stateModule = await import('../../../static/js/state/index.js');
|
||||||
|
stateModule.initPageState('loras');
|
||||||
|
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
|
||||||
|
return { stateModule, controls: new LorasControls() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Random sort option', () => {
|
||||||
|
it('generates a seeded sort value when Random is picked', async () => {
|
||||||
|
renderControlsDom('loras');
|
||||||
|
const { controls } = await createControls();
|
||||||
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
|
const randomOpt = sortSelect.querySelector('option[value="random"]');
|
||||||
|
|
||||||
|
sortSelect.value = 'random';
|
||||||
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(controls.pageState.sortBy).toMatch(/^random:[a-z0-9]+$/);
|
||||||
|
expect(localStorage.getItem('lora_manager_loras_sort')).toBe(controls.pageState.sortBy);
|
||||||
|
expect(randomOpt.value).toBe(controls.pageState.sortBy);
|
||||||
|
expect(sortSelect.value).toBe(controls.pageState.sortBy);
|
||||||
|
expect(resetAndReloadMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reshuffles with a fresh seed every time Random is picked again', async () => {
|
||||||
|
renderControlsDom('loras');
|
||||||
|
const { controls } = await createControls();
|
||||||
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
|
const randomOpt = sortSelect.querySelector('option[value="random"]');
|
||||||
|
|
||||||
|
// First pick
|
||||||
|
sortSelect.value = 'random';
|
||||||
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
const firstSeed = controls.pageState.sortBy;
|
||||||
|
|
||||||
|
// Second pick: the option now carries the seeded value, like a menu click
|
||||||
|
sortSelect.value = randomOpt.value;
|
||||||
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(controls.pageState.sortBy).toMatch(/^random:[a-z0-9]+$/);
|
||||||
|
expect(controls.pageState.sortBy).not.toBe(firstSeed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores a persisted seeded random sort on load', async () => {
|
||||||
|
renderControlsDom('loras');
|
||||||
|
const savedSort = 'random:persistedseed';
|
||||||
|
localStorage.setItem('lora_manager_loras_sort', savedSort);
|
||||||
|
|
||||||
|
const { controls } = await createControls();
|
||||||
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
|
|
||||||
|
expect(controls.pageState.sortBy).toBe(savedSort);
|
||||||
|
expect(sortSelect.value).toBe(savedSort);
|
||||||
|
expect(sortSelect.querySelector('option[value="random:persistedseed"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a non-random sort back to the plain random option', async () => {
|
||||||
|
renderControlsDom('loras');
|
||||||
|
const { controls } = await createControls();
|
||||||
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
|
const randomOpt = sortSelect.querySelector('option[value="random"]');
|
||||||
|
|
||||||
|
// Seed a random sort, then switch to a normal sort
|
||||||
|
sortSelect.value = 'random';
|
||||||
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
controls.applySortToSelect('name:desc');
|
||||||
|
|
||||||
|
expect(sortSelect.value).toBe('name:desc');
|
||||||
|
expect(randomOpt.value).toBe('random');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the seeded option when switching away from Random via the dropdown change handler', async () => {
|
||||||
|
renderControlsDom('loras');
|
||||||
|
const { controls } = await createControls();
|
||||||
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
|
const randomOpt = sortSelect.querySelector('option[value="random"]');
|
||||||
|
|
||||||
|
// Pick Random: the option is now seeded
|
||||||
|
sortSelect.value = 'random';
|
||||||
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(randomOpt.value).toMatch(/^random:[a-z0-9]+$/);
|
||||||
|
|
||||||
|
// Switch to a non-random sort through the change handler (as a menu
|
||||||
|
// click does); the option must go back to the plain "random" value
|
||||||
|
sortSelect.value = 'name:desc';
|
||||||
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(controls.pageState.sortBy).toBe('name:desc');
|
||||||
|
expect(sortSelect.value).toBe('name:desc');
|
||||||
|
expect(randomOpt.value).toBe('random');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, beforeEach, expect } from 'vitest';
|
||||||
|
import { initSortDropdown } from '../../../static/js/components/controls/SortDropdown.js';
|
||||||
|
|
||||||
|
function renderSortDropdownDom() {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="sort-dropdown-group">
|
||||||
|
<select id="sortSelect">
|
||||||
|
<option value="name:asc">Name Asc</option>
|
||||||
|
<option value="name:desc">Name Desc</option>
|
||||||
|
<option value="random" selected>Randomize (shuffle)</option>
|
||||||
|
</select>
|
||||||
|
<button class="sort-trigger" type="button">
|
||||||
|
<span class="sort-trigger__label"></span>
|
||||||
|
</button>
|
||||||
|
<div class="sort-dropdown-menu"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return {
|
||||||
|
select: document.getElementById('sortSelect'),
|
||||||
|
menu: document.querySelector('.sort-dropdown-menu'),
|
||||||
|
label: document.querySelector('.sort-trigger__label'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SortDropdown menu sync', () => {
|
||||||
|
let select;
|
||||||
|
let menu;
|
||||||
|
let label;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ select, menu, label } = renderSortDropdownDom());
|
||||||
|
initSortDropdown(select);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds the menu and highlights the selected item when an option value attribute changes', async () => {
|
||||||
|
// The seeded Random option gets a new value each time it is picked.
|
||||||
|
// The select's value getter follows the selected option's new value.
|
||||||
|
const randomOpt = select.querySelector('option[value="random"]');
|
||||||
|
randomOpt.value = 'random:abc123';
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const items = [...menu.querySelectorAll('.sort-option')];
|
||||||
|
expect(items.map((el) => el.dataset.value)).toContain('random:abc123');
|
||||||
|
const seededItem = items.find((el) => el.dataset.value === 'random:abc123');
|
||||||
|
expect(seededItem.classList.contains('is-selected')).toBe(true);
|
||||||
|
expect(label.textContent).toBe('Randomize (shuffle)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the stale seeded item and re-selects the plain random item when the option is reset', async () => {
|
||||||
|
const randomOpt = select.querySelector('option[value="random"]');
|
||||||
|
randomOpt.value = 'random:abc123';
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
// The rebuild must have happened: the seeded item is in the menu
|
||||||
|
const seededItems = [...menu.querySelectorAll('.sort-option')]
|
||||||
|
.filter((el) => el.dataset.value === 'random:abc123');
|
||||||
|
expect(seededItems).toHaveLength(1);
|
||||||
|
|
||||||
|
// PageControls resets the option to "random" when switching away
|
||||||
|
randomOpt.value = 'random';
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const items = [...menu.querySelectorAll('.sort-option')];
|
||||||
|
expect(items.map((el) => el.dataset.value)).not.toContain('random:abc123');
|
||||||
|
const randomItem = items.find((el) => el.dataset.value === 'random');
|
||||||
|
expect(randomItem.classList.contains('is-selected')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { APP_MODULE, EXTENSION_MODULE, appMock, registeredExtensions } =
|
||||||
|
vi.hoisted(() => {
|
||||||
|
const registeredExtensions = [];
|
||||||
|
const appMock = {
|
||||||
|
configuringGraph: false,
|
||||||
|
registerExtension: (ext) => registeredExtensions.push(ext),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
|
||||||
|
EXTENSION_MODULE: new URL(
|
||||||
|
"../../../web/comfyui/lora_stack_dynamic_inputs.js",
|
||||||
|
import.meta.url
|
||||||
|
).pathname,
|
||||||
|
appMock,
|
||||||
|
registeredExtensions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock(APP_MODULE, () => ({
|
||||||
|
app: appMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Lora Stack Combiner dynamic inputs", () => {
|
||||||
|
let extension;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
registeredExtensions.length = 0;
|
||||||
|
appMock.configuringGraph = false;
|
||||||
|
await import(EXTENSION_MODULE);
|
||||||
|
extension = registeredExtensions.find(
|
||||||
|
(ext) => ext.name === "Comfy.LoraManager.LoraStackCombiner"
|
||||||
|
);
|
||||||
|
expect(extension).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createNodeType() {
|
||||||
|
const nodeType = { prototype: {} };
|
||||||
|
extension.beforeRegisterNodeDef(
|
||||||
|
nodeType,
|
||||||
|
{ name: "Lora Stack Combiner (LoraManager)" },
|
||||||
|
appMock
|
||||||
|
);
|
||||||
|
return nodeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNode(inputs = []) {
|
||||||
|
const node = {
|
||||||
|
comfyClass: "Lora Stack Combiner (LoraManager)",
|
||||||
|
inputs: inputs.map((name) => ({ name, type: "LORA_STACK" })),
|
||||||
|
addInput: vi.fn(function (name, type, opts) {
|
||||||
|
this.inputs.push({ name, type, ...opts });
|
||||||
|
}),
|
||||||
|
removeInput: vi.fn(function (index) {
|
||||||
|
this.inputs.splice(index, 1);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLinkInfo() {
|
||||||
|
return { id: 999, origin_id: 1, target_id: 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("adds a third input when the last slot gets connected", () => {
|
||||||
|
const nodeType = createNodeType();
|
||||||
|
const node = createNode(["lora_stack1", "lora_stack2"]);
|
||||||
|
node.onConnectionsChange = nodeType.prototype.onConnectionsChange;
|
||||||
|
|
||||||
|
node.onConnectionsChange(1, 1, true, makeLinkInfo());
|
||||||
|
|
||||||
|
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||||
|
"lora_stack1",
|
||||||
|
"lora_stack2",
|
||||||
|
"lora_stack3",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add an input when a non-last slot gets connected", () => {
|
||||||
|
const nodeType = createNodeType();
|
||||||
|
const node = createNode(["lora_stack1", "lora_stack2", "lora_stack3"]);
|
||||||
|
node.onConnectionsChange = nodeType.prototype.onConnectionsChange;
|
||||||
|
|
||||||
|
node.onConnectionsChange(1, 0, true, makeLinkInfo());
|
||||||
|
|
||||||
|
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||||
|
"lora_stack1",
|
||||||
|
"lora_stack2",
|
||||||
|
"lora_stack3",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a disconnected middle slot and renumbers", () => {
|
||||||
|
// Simulates a real LiteGraph disconnect event: it fires only for slots that
|
||||||
|
// had a link, and input.link has already been cleared before the event fires.
|
||||||
|
const nodeType = createNodeType();
|
||||||
|
const node = createNode(["lora_stack1", "lora_stack2", "lora_stack3"]);
|
||||||
|
node.inputs[0].link = 11;
|
||||||
|
node.inputs[1].link = null; // slot 2 was just disconnected
|
||||||
|
node.inputs[2].link = 13;
|
||||||
|
node.onConnectionsChange = nodeType.prototype.onConnectionsChange;
|
||||||
|
|
||||||
|
node.onConnectionsChange(1, 1, false, makeLinkInfo());
|
||||||
|
|
||||||
|
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||||
|
"lora_stack1",
|
||||||
|
"lora_stack2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the last slot when it is disconnected", () => {
|
||||||
|
const nodeType = createNodeType();
|
||||||
|
const node = createNode(["lora_stack1", "lora_stack2", "lora_stack3"]);
|
||||||
|
node.inputs[0].link = 11;
|
||||||
|
node.inputs[1].link = 12;
|
||||||
|
node.inputs[2].link = null; // last slot was just disconnected
|
||||||
|
node.onConnectionsChange = nodeType.prototype.onConnectionsChange;
|
||||||
|
|
||||||
|
node.onConnectionsChange(1, 2, false, makeLinkInfo());
|
||||||
|
|
||||||
|
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||||
|
"lora_stack1",
|
||||||
|
"lora_stack2",
|
||||||
|
"lora_stack3",
|
||||||
|
]);
|
||||||
|
expect(node.removeInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps at least two inputs when disconnecting", () => {
|
||||||
|
const nodeType = createNodeType();
|
||||||
|
const node = createNode(["lora_stack1", "lora_stack2"]);
|
||||||
|
node.inputs[0].link = 11;
|
||||||
|
node.inputs[1].link = null; // slot 2 was just disconnected
|
||||||
|
node.onConnectionsChange = nodeType.prototype.onConnectionsChange;
|
||||||
|
|
||||||
|
node.onConnectionsChange(1, 1, false, makeLinkInfo());
|
||||||
|
|
||||||
|
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||||
|
"lora_stack1",
|
||||||
|
"lora_stack2",
|
||||||
|
]);
|
||||||
|
expect(node.removeInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing while the graph is being configured", () => {
|
||||||
|
appMock.configuringGraph = true;
|
||||||
|
const nodeType = createNodeType();
|
||||||
|
const node = createNode(["lora_stack1", "lora_stack2"]);
|
||||||
|
node.onConnectionsChange = nodeType.prototype.onConnectionsChange;
|
||||||
|
|
||||||
|
node.onConnectionsChange(1, 1, true, makeLinkInfo());
|
||||||
|
|
||||||
|
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||||
|
"lora_stack1",
|
||||||
|
"lora_stack2",
|
||||||
|
]);
|
||||||
|
expect(node.addInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves legacy lora_stack_a/b inputs untouched", () => {
|
||||||
|
const nodeType = createNodeType();
|
||||||
|
const node = createNode(["lora_stack_a", "lora_stack_b"]);
|
||||||
|
node.onConnectionsChange = nodeType.prototype.onConnectionsChange;
|
||||||
|
|
||||||
|
node.onConnectionsChange(1, 0, true, makeLinkInfo());
|
||||||
|
|
||||||
|
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||||
|
"lora_stack_a",
|
||||||
|
"lora_stack_b",
|
||||||
|
]);
|
||||||
|
expect(node.addInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ensures two numbered inputs exist on creation", () => {
|
||||||
|
const node = createNode([]);
|
||||||
|
extension.nodeCreated(node, {});
|
||||||
|
|
||||||
|
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||||
|
"lora_stack1",
|
||||||
|
"lora_stack2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add numbered inputs to legacy workflows", () => {
|
||||||
|
const node = createNode(["lora_stack_a", "lora_stack_b"]);
|
||||||
|
extension.nodeCreated(node, {});
|
||||||
|
|
||||||
|
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||||
|
"lora_stack_a",
|
||||||
|
"lora_stack_b",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user