mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-14 17:53:21 -03:00
Compare commits
42 Commits
6ca411e4e4
...
v1.1.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ffe65d983c | |||
| b0b5be913c | |||
| 01efcbc584 | |||
| 02c249917a | |||
| 419bbc90b2 | |||
| b0c4510fdb | |||
| bf6a614e0d | |||
| feab01cd9c | |||
| 966024e534 | |||
| 2018722cc8 | |||
| 9d85c2a44a | |||
| 03dd047e62 | |||
| 86b547c1e0 | |||
| bab9752c8b | |||
| 774cc1be86 | |||
| 234b73c8a2 | |||
| abd06c48f4 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ try: # pragma: no cover - import fallback for pytest collection
|
|||||||
from .py.nodes.lora_pool import LoraPoolLM
|
from .py.nodes.lora_pool import LoraPoolLM
|
||||||
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_syntax_to_path import LoraSyntaxToPath
|
||||||
from .py.metadata_collector import init as init_metadata_collector
|
from .py.metadata_collector import init as init_metadata_collector
|
||||||
except (
|
except (
|
||||||
ImportError
|
ImportError
|
||||||
@@ -56,6 +58,10 @@ except (
|
|||||||
"py.nodes.lora_randomizer"
|
"py.nodes.lora_randomizer"
|
||||||
).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
|
||||||
|
LoraSyntaxToPath = importlib.import_module(
|
||||||
|
"py.nodes.lora_syntax_to_path"
|
||||||
|
).LoraSyntaxToPath
|
||||||
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 = {
|
||||||
@@ -75,6 +81,8 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
LoraPoolLM.NAME: LoraPoolLM,
|
LoraPoolLM.NAME: LoraPoolLM,
|
||||||
LoraRandomizerLM.NAME: LoraRandomizerLM,
|
LoraRandomizerLM.NAME: LoraRandomizerLM,
|
||||||
LoraCyclerLM.NAME: LoraCyclerLM,
|
LoraCyclerLM.NAME: LoraCyclerLM,
|
||||||
|
LoraInfoLM.NAME: LoraInfoLM,
|
||||||
|
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web/comfyui"
|
WEB_DIRECTORY = "./web/comfyui"
|
||||||
|
|||||||
+391
-362
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
+18
-5
@@ -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",
|
||||||
@@ -505,7 +507,9 @@
|
|||||||
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
|
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
|
||||||
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
|
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "Dieser Pfad ist bereits konfiguriert"
|
"duplicatePath": "Dieser Pfad ist bereits konfiguriert",
|
||||||
|
"checkpointUnetOverlap": "Derselbe Pfad kann nicht für Checkpoints und Diffusionsmodelle verwendet werden: {paths}",
|
||||||
|
"checkpointUnetOverlapInline": "Dieser Pfad wird bereits für einen anderen Modelltyp verwendet. Bitte verwenden Sie separate Ordner für Checkpoints und Diffusionsmodelle."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -638,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",
|
||||||
@@ -1205,7 +1215,9 @@
|
|||||||
"preparing": "Download wird vorbereitet...",
|
"preparing": "Download wird vorbereitet...",
|
||||||
"downloadedPreview": "Vorschaubild heruntergeladen",
|
"downloadedPreview": "Vorschaubild heruntergeladen",
|
||||||
"downloadingFile": "{type}-Datei wird heruntergeladen",
|
"downloadingFile": "{type}-Datei wird heruntergeladen",
|
||||||
"finalizing": "Download wird abgeschlossen..."
|
"finalizing": "Download wird abgeschlossen...",
|
||||||
|
"cancelling": "Download wird abgebrochen...",
|
||||||
|
"cancelled": "Download abgebrochen"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "Aktuelle Datei:",
|
"currentFile": "Aktuelle Datei:",
|
||||||
@@ -2013,7 +2025,8 @@
|
|||||||
"imagesCompleted": "Beispielbilder {action} abgeschlossen",
|
"imagesCompleted": "Beispielbilder {action} abgeschlossen",
|
||||||
"imagesFailed": "Beispielbilder {action} fehlgeschlagen",
|
"imagesFailed": "Beispielbilder {action} fehlgeschlagen",
|
||||||
"loadError": "Fehler beim Laden der Downloads: {message}",
|
"loadError": "Fehler beim Laden der Downloads: {message}",
|
||||||
"downloadError": "Download-Fehler: {message}"
|
"downloadError": "Download-Fehler: {message}",
|
||||||
|
"downloadStopped": "Download abgebrochen"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
|
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
|
||||||
|
|||||||
+18
-5
@@ -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",
|
||||||
@@ -505,7 +507,9 @@
|
|||||||
"saveSuccess": "Extra folder paths updated. Restart required to apply changes.",
|
"saveSuccess": "Extra folder paths updated. Restart required to apply changes.",
|
||||||
"saveError": "Failed to update extra folder paths: {message}",
|
"saveError": "Failed to update extra folder paths: {message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "This path is already configured"
|
"duplicatePath": "This path is already configured",
|
||||||
|
"checkpointUnetOverlap": "Cannot use the same path for both checkpoints and diffusion models: {paths}",
|
||||||
|
"checkpointUnetOverlapInline": "This path is also used for a different model type. Use separate folders for checkpoints and diffusion models."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -638,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",
|
||||||
@@ -1205,7 +1215,9 @@
|
|||||||
"preparing": "Preparing download...",
|
"preparing": "Preparing download...",
|
||||||
"downloadedPreview": "Downloaded preview image",
|
"downloadedPreview": "Downloaded preview image",
|
||||||
"downloadingFile": "Downloading {type} file",
|
"downloadingFile": "Downloading {type} file",
|
||||||
"finalizing": "Finalizing download..."
|
"finalizing": "Finalizing download...",
|
||||||
|
"cancelling": "Cancelling download...",
|
||||||
|
"cancelled": "Download cancelled"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "Current file:",
|
"currentFile": "Current file:",
|
||||||
@@ -2013,7 +2025,8 @@
|
|||||||
"imagesCompleted": "Example images {action} completed",
|
"imagesCompleted": "Example images {action} completed",
|
||||||
"imagesFailed": "Example images {action} failed",
|
"imagesFailed": "Example images {action} failed",
|
||||||
"loadError": "Error loading downloads: {message}",
|
"loadError": "Error loading downloads: {message}",
|
||||||
"downloadError": "Download error: {message}"
|
"downloadError": "Download error: {message}",
|
||||||
|
"downloadStopped": "Download cancelled"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "Failed to load folder tree",
|
"folderTreeFailed": "Failed to load folder tree",
|
||||||
|
|||||||
+18
-5
@@ -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",
|
||||||
@@ -505,7 +507,9 @@
|
|||||||
"saveSuccess": "Rutas de carpetas adicionales actualizadas. Se requiere reinicio para aplicar los cambios.",
|
"saveSuccess": "Rutas de carpetas adicionales actualizadas. Se requiere reinicio para aplicar los cambios.",
|
||||||
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
|
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "Esta ruta ya está configurada"
|
"duplicatePath": "Esta ruta ya está configurada",
|
||||||
|
"checkpointUnetOverlap": "No se puede usar la misma ruta para checkpoints y modelos de difusión: {paths}",
|
||||||
|
"checkpointUnetOverlapInline": "Esta ruta ya se usa para otro tipo de modelo. Use carpetas separadas para checkpoints y modelos de difusión."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -638,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",
|
||||||
@@ -1205,7 +1215,9 @@
|
|||||||
"preparing": "Preparando descarga...",
|
"preparing": "Preparando descarga...",
|
||||||
"downloadedPreview": "Imagen de vista previa descargada",
|
"downloadedPreview": "Imagen de vista previa descargada",
|
||||||
"downloadingFile": "Descargando archivo de {type}",
|
"downloadingFile": "Descargando archivo de {type}",
|
||||||
"finalizing": "Finalizando descarga..."
|
"finalizing": "Finalizando descarga...",
|
||||||
|
"cancelling": "Cancelando descarga...",
|
||||||
|
"cancelled": "Descarga cancelada"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "Archivo actual:",
|
"currentFile": "Archivo actual:",
|
||||||
@@ -2013,7 +2025,8 @@
|
|||||||
"imagesCompleted": "Imágenes de ejemplo {action} completadas",
|
"imagesCompleted": "Imágenes de ejemplo {action} completadas",
|
||||||
"imagesFailed": "Imágenes de ejemplo {action} fallidas",
|
"imagesFailed": "Imágenes de ejemplo {action} fallidas",
|
||||||
"loadError": "Error al cargar descargas: {message}",
|
"loadError": "Error al cargar descargas: {message}",
|
||||||
"downloadError": "Error de descarga: {message}"
|
"downloadError": "Error de descarga: {message}",
|
||||||
|
"downloadStopped": "Descarga cancelada"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "Error al cargar árbol de carpetas",
|
"folderTreeFailed": "Error al cargar árbol de carpetas",
|
||||||
|
|||||||
+18
-5
@@ -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",
|
||||||
@@ -505,7 +507,9 @@
|
|||||||
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour. Redémarrage requis pour appliquer les changements.",
|
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour. Redémarrage requis pour appliquer les changements.",
|
||||||
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
|
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "Ce chemin est déjà configuré"
|
"duplicatePath": "Ce chemin est déjà configuré",
|
||||||
|
"checkpointUnetOverlap": "Impossible d'utiliser le même chemin pour les checkpoints et les modèles de diffusion : {paths}",
|
||||||
|
"checkpointUnetOverlapInline": "Ce chemin est déjà utilisé pour un autre type de modèle. Utilisez des dossiers séparés pour les checkpoints et les modèles de diffusion."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -638,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",
|
||||||
@@ -1205,7 +1215,9 @@
|
|||||||
"preparing": "Préparation du téléchargement...",
|
"preparing": "Préparation du téléchargement...",
|
||||||
"downloadedPreview": "Image d'aperçu téléchargée",
|
"downloadedPreview": "Image d'aperçu téléchargée",
|
||||||
"downloadingFile": "Téléchargement du fichier {type}",
|
"downloadingFile": "Téléchargement du fichier {type}",
|
||||||
"finalizing": "Finalisation du téléchargement..."
|
"finalizing": "Finalisation du téléchargement...",
|
||||||
|
"cancelling": "Annulation du téléchargement...",
|
||||||
|
"cancelled": "Téléchargement annulé"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "Fichier actuel :",
|
"currentFile": "Fichier actuel :",
|
||||||
@@ -2013,7 +2025,8 @@
|
|||||||
"imagesCompleted": "Images d'exemple {action} terminées",
|
"imagesCompleted": "Images d'exemple {action} terminées",
|
||||||
"imagesFailed": "Images d'exemple {action} échouées",
|
"imagesFailed": "Images d'exemple {action} échouées",
|
||||||
"loadError": "Erreur lors du chargement des téléchargements : {message}",
|
"loadError": "Erreur lors du chargement des téléchargements : {message}",
|
||||||
"downloadError": "Erreur de téléchargement : {message}"
|
"downloadError": "Erreur de téléchargement : {message}",
|
||||||
|
"downloadStopped": "Téléchargement annulé"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
|
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
|
||||||
|
|||||||
+18
-5
@@ -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": "נקה את כל המסננים",
|
||||||
@@ -505,7 +507,9 @@
|
|||||||
"saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
|
"saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
|
||||||
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
|
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "נתיב זה כבר מוגדר"
|
"duplicatePath": "נתיב זה כבר מוגדר",
|
||||||
|
"checkpointUnetOverlap": "לא ניתן להשתמש באותו נתיב עבור checkpoints ומודלי דיפוזיה: {paths}",
|
||||||
|
"checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -638,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": "הפעל פרוקסי ברמת האפליקציה",
|
||||||
@@ -1205,7 +1215,9 @@
|
|||||||
"preparing": "מכין הורדה...",
|
"preparing": "מכין הורדה...",
|
||||||
"downloadedPreview": "תמונת תצוגה מקדימה הורדה",
|
"downloadedPreview": "תמונת תצוגה מקדימה הורדה",
|
||||||
"downloadingFile": "מוריד קובץ {type}",
|
"downloadingFile": "מוריד קובץ {type}",
|
||||||
"finalizing": "מסיים הורדה..."
|
"finalizing": "מסיים הורדה...",
|
||||||
|
"cancelling": "מבטל הורדה...",
|
||||||
|
"cancelled": "ההורדה בוטלה"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "הקובץ הנוכחי:",
|
"currentFile": "הקובץ הנוכחי:",
|
||||||
@@ -2013,7 +2025,8 @@
|
|||||||
"imagesCompleted": "{action} תמונות הדוגמה הושלם",
|
"imagesCompleted": "{action} תמונות הדוגמה הושלם",
|
||||||
"imagesFailed": "{action} תמונות הדוגמה נכשל",
|
"imagesFailed": "{action} תמונות הדוגמה נכשל",
|
||||||
"loadError": "שגיאה בטעינת הורדות: {message}",
|
"loadError": "שגיאה בטעינת הורדות: {message}",
|
||||||
"downloadError": "שגיאת הורדה: {message}"
|
"downloadError": "שגיאת הורדה: {message}",
|
||||||
|
"downloadStopped": "ההורדה בוטלה"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "טעינת עץ התיקיות נכשלה",
|
"folderTreeFailed": "טעינת עץ התיקיות נכשלה",
|
||||||
|
|||||||
+18
-5
@@ -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": "すべてのフィルタをクリア",
|
||||||
@@ -505,7 +507,9 @@
|
|||||||
"saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。",
|
"saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。",
|
||||||
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
|
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "このパスはすでに設定されています"
|
"duplicatePath": "このパスはすでに設定されています",
|
||||||
|
"checkpointUnetOverlap": "checkpoints と diffusion models に同じパスは使用できません:{paths}",
|
||||||
|
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。checkpoints と diffusion models には別々のフォルダを使用してください。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -638,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": "アプリレベルのプロキシを有効化",
|
||||||
@@ -1205,7 +1215,9 @@
|
|||||||
"preparing": "ダウンロードを準備中...",
|
"preparing": "ダウンロードを準備中...",
|
||||||
"downloadedPreview": "プレビュー画像をダウンロードしました",
|
"downloadedPreview": "プレビュー画像をダウンロードしました",
|
||||||
"downloadingFile": "{type}ファイルをダウンロード中",
|
"downloadingFile": "{type}ファイルをダウンロード中",
|
||||||
"finalizing": "ダウンロードを完了中..."
|
"finalizing": "ダウンロードを完了中...",
|
||||||
|
"cancelling": "ダウンロードをキャンセル中...",
|
||||||
|
"cancelled": "ダウンロードをキャンセルしました"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "現在のファイル:",
|
"currentFile": "現在のファイル:",
|
||||||
@@ -2013,7 +2025,8 @@
|
|||||||
"imagesCompleted": "例画像 {action} が完了しました",
|
"imagesCompleted": "例画像 {action} が完了しました",
|
||||||
"imagesFailed": "例画像 {action} が失敗しました",
|
"imagesFailed": "例画像 {action} が失敗しました",
|
||||||
"loadError": "ダウンロード読み込みエラー:{message}",
|
"loadError": "ダウンロード読み込みエラー:{message}",
|
||||||
"downloadError": "ダウンロードエラー:{message}"
|
"downloadError": "ダウンロードエラー:{message}",
|
||||||
|
"downloadStopped": "ダウンロードをキャンセルしました"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
|
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
|
||||||
|
|||||||
+18
-5
@@ -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": "모든 필터 지우기",
|
||||||
@@ -505,7 +507,9 @@
|
|||||||
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
|
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
|
||||||
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
|
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "이 경로는 이미 구성되어 있습니다"
|
"duplicatePath": "이 경로는 이미 구성되어 있습니다",
|
||||||
|
"checkpointUnetOverlap": "checkpoints와 diffusion models에 동일한 경로를 사용할 수 없습니다: {paths}",
|
||||||
|
"checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -638,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": "앱 수준 프록시 활성화",
|
||||||
@@ -1205,7 +1215,9 @@
|
|||||||
"preparing": "다운로드 준비 중...",
|
"preparing": "다운로드 준비 중...",
|
||||||
"downloadedPreview": "미리보기 이미지 다운로드됨",
|
"downloadedPreview": "미리보기 이미지 다운로드됨",
|
||||||
"downloadingFile": "{type} 파일 다운로드 중",
|
"downloadingFile": "{type} 파일 다운로드 중",
|
||||||
"finalizing": "다운로드 완료 중..."
|
"finalizing": "다운로드 완료 중...",
|
||||||
|
"cancelling": "다운로드 취소 중...",
|
||||||
|
"cancelled": "다운로드가 취소되었습니다"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "현재 파일:",
|
"currentFile": "현재 파일:",
|
||||||
@@ -2013,7 +2025,8 @@
|
|||||||
"imagesCompleted": "예시 이미지 {action}이(가) 완료되었습니다",
|
"imagesCompleted": "예시 이미지 {action}이(가) 완료되었습니다",
|
||||||
"imagesFailed": "예시 이미지 {action}이(가) 실패했습니다",
|
"imagesFailed": "예시 이미지 {action}이(가) 실패했습니다",
|
||||||
"loadError": "다운로드 로딩 오류: {message}",
|
"loadError": "다운로드 로딩 오류: {message}",
|
||||||
"downloadError": "다운로드 오류: {message}"
|
"downloadError": "다운로드 오류: {message}",
|
||||||
|
"downloadStopped": "다운로드가 취소되었습니다"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "폴더 트리 로딩 실패",
|
"folderTreeFailed": "폴더 트리 로딩 실패",
|
||||||
|
|||||||
+18
-5
@@ -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": "Очистить все фильтры",
|
||||||
@@ -505,7 +507,9 @@
|
|||||||
"saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.",
|
"saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.",
|
||||||
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
|
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "Этот путь уже настроен"
|
"duplicatePath": "Этот путь уже настроен",
|
||||||
|
"checkpointUnetOverlap": "Нельзя использовать один и тот же путь для checkpoints и diffusion models: {paths}",
|
||||||
|
"checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -638,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": "Включить прокси на уровне приложения",
|
||||||
@@ -1205,7 +1215,9 @@
|
|||||||
"preparing": "Подготовка загрузки...",
|
"preparing": "Подготовка загрузки...",
|
||||||
"downloadedPreview": "Превью изображение загружено",
|
"downloadedPreview": "Превью изображение загружено",
|
||||||
"downloadingFile": "Загрузка файла {type}",
|
"downloadingFile": "Загрузка файла {type}",
|
||||||
"finalizing": "Завершение загрузки..."
|
"finalizing": "Завершение загрузки...",
|
||||||
|
"cancelling": "Отмена загрузки...",
|
||||||
|
"cancelled": "Загрузка отменена"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "Текущий файл:",
|
"currentFile": "Текущий файл:",
|
||||||
@@ -2013,7 +2025,8 @@
|
|||||||
"imagesCompleted": "Примеры изображений {action} завершены",
|
"imagesCompleted": "Примеры изображений {action} завершены",
|
||||||
"imagesFailed": "Примеры изображений {action} не удались",
|
"imagesFailed": "Примеры изображений {action} не удались",
|
||||||
"loadError": "Ошибка загрузки downloads: {message}",
|
"loadError": "Ошибка загрузки downloads: {message}",
|
||||||
"downloadError": "Ошибка загрузки: {message}"
|
"downloadError": "Ошибка загрузки: {message}",
|
||||||
|
"downloadStopped": "Загрузка отменена"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "Не удалось загрузить дерево папок",
|
"folderTreeFailed": "Не удалось загрузить дерево папок",
|
||||||
|
|||||||
+18
-5
@@ -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": "清除所有筛选",
|
||||||
@@ -505,7 +507,9 @@
|
|||||||
"saveSuccess": "额外文件夹路径已更新,需要重启才能生效。",
|
"saveSuccess": "额外文件夹路径已更新,需要重启才能生效。",
|
||||||
"saveError": "更新额外文件夹路径失败:{message}",
|
"saveError": "更新额外文件夹路径失败:{message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "此路径已配置"
|
"duplicatePath": "此路径已配置",
|
||||||
|
"checkpointUnetOverlap": "checkpoints 和 diffusion models 不能使用相同的路径:{paths}",
|
||||||
|
"checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -638,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": "启用应用级代理",
|
||||||
@@ -1205,7 +1215,9 @@
|
|||||||
"preparing": "正在准备下载...",
|
"preparing": "正在准备下载...",
|
||||||
"downloadedPreview": "预览图片已下载",
|
"downloadedPreview": "预览图片已下载",
|
||||||
"downloadingFile": "正在下载 {type} 文件",
|
"downloadingFile": "正在下载 {type} 文件",
|
||||||
"finalizing": "正在完成下载..."
|
"finalizing": "正在完成下载...",
|
||||||
|
"cancelling": "取消下载中...",
|
||||||
|
"cancelled": "下载已取消"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "当前文件:",
|
"currentFile": "当前文件:",
|
||||||
@@ -2013,7 +2025,8 @@
|
|||||||
"imagesCompleted": "示例图片{action}完成",
|
"imagesCompleted": "示例图片{action}完成",
|
||||||
"imagesFailed": "示例图片{action}失败",
|
"imagesFailed": "示例图片{action}失败",
|
||||||
"loadError": "加载下载项出错:{message}",
|
"loadError": "加载下载项出错:{message}",
|
||||||
"downloadError": "下载错误:{message}"
|
"downloadError": "下载错误:{message}",
|
||||||
|
"downloadStopped": "下载已取消"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "加载文件夹树失败",
|
"folderTreeFailed": "加载文件夹树失败",
|
||||||
|
|||||||
+18
-5
@@ -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": "清除所有篩選",
|
||||||
@@ -505,7 +507,9 @@
|
|||||||
"saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。",
|
"saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。",
|
||||||
"saveError": "更新額外資料夾路徑失敗:{message}",
|
"saveError": "更新額外資料夾路徑失敗:{message}",
|
||||||
"validation": {
|
"validation": {
|
||||||
"duplicatePath": "此路徑已設定"
|
"duplicatePath": "此路徑已設定",
|
||||||
|
"checkpointUnetOverlap": "checkpoints 和 diffusion models 不能使用相同的路徑:{paths}",
|
||||||
|
"checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priorityTags": {
|
"priorityTags": {
|
||||||
@@ -638,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": "啟用應用程式代理",
|
||||||
@@ -1205,7 +1215,9 @@
|
|||||||
"preparing": "準備下載中...",
|
"preparing": "準備下載中...",
|
||||||
"downloadedPreview": "已下載預覽圖片",
|
"downloadedPreview": "已下載預覽圖片",
|
||||||
"downloadingFile": "正在下載 {type} 檔案",
|
"downloadingFile": "正在下載 {type} 檔案",
|
||||||
"finalizing": "完成下載中..."
|
"finalizing": "完成下載中...",
|
||||||
|
"cancelling": "取消下載中...",
|
||||||
|
"cancelled": "下載已取消"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "目前檔案:",
|
"currentFile": "目前檔案:",
|
||||||
@@ -2013,7 +2025,8 @@
|
|||||||
"imagesCompleted": "範例圖片{action}完成",
|
"imagesCompleted": "範例圖片{action}完成",
|
||||||
"imagesFailed": "範例圖片{action}失敗",
|
"imagesFailed": "範例圖片{action}失敗",
|
||||||
"loadError": "載入下載時發生錯誤:{message}",
|
"loadError": "載入下載時發生錯誤:{message}",
|
||||||
"downloadError": "下載錯誤:{message}"
|
"downloadError": "下載錯誤:{message}",
|
||||||
|
"downloadStopped": "下載已取消"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"folderTreeFailed": "載入資料夾樹狀結構失敗",
|
"folderTreeFailed": "載入資料夾樹狀結構失敗",
|
||||||
|
|||||||
+47
-4
@@ -208,6 +208,12 @@ class Config:
|
|||||||
if not isinstance(library_config, dict):
|
if not isinstance(library_config, dict):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Always read recipes_path — it is independent of extra folder paths
|
||||||
|
# and must be set before any early returns below.
|
||||||
|
recipes_path = library_config.get("recipes_path", "")
|
||||||
|
if isinstance(recipes_path, str) and recipes_path:
|
||||||
|
self.recipes_path = recipes_path
|
||||||
|
|
||||||
extra_folder_paths = library_config.get("extra_folder_paths")
|
extra_folder_paths = library_config.get("extra_folder_paths")
|
||||||
if not isinstance(extra_folder_paths, dict):
|
if not isinstance(extra_folder_paths, dict):
|
||||||
return
|
return
|
||||||
@@ -233,10 +239,6 @@ class Config:
|
|||||||
extra_embedding
|
extra_embedding
|
||||||
)
|
)
|
||||||
|
|
||||||
recipes_path = library_config.get("recipes_path", "")
|
|
||||||
if isinstance(recipes_path, str) and recipes_path:
|
|
||||||
self.recipes_path = recipes_path
|
|
||||||
|
|
||||||
if self.extra_loras_roots:
|
if self.extra_loras_roots:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Found extra LoRA roots:"
|
"Found extra LoRA roots:"
|
||||||
@@ -357,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 []),
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Lora Info display node — pure frontend node for showing selected LoRA info.
|
||||||
|
|
||||||
|
This node does NOT participate in workflow execution. Its single optional
|
||||||
|
"lora_source" input exists solely as a wire-connection anchor so that the
|
||||||
|
frontend can traverse the graph and push selection data to connected info nodes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class LoraInfoLM:
|
||||||
|
"""Display node that shows filename and notes for the selected LoRA."""
|
||||||
|
|
||||||
|
NAME = "Lora Info (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/utils"
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Displays information (filename, notes) about the currently selected "
|
||||||
|
"LoRA. Connect any output from a LoRA Loader or Stacker to the "
|
||||||
|
"lora_source input, then select a LoRA in the source widget — the "
|
||||||
|
"info updates automatically. Does not affect workflow execution."
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ()
|
||||||
|
RETURN_NAMES = ()
|
||||||
|
OUTPUT_NODE = False
|
||||||
|
FUNCTION = "noop"
|
||||||
|
|
||||||
|
def noop(self, **kwargs):
|
||||||
|
# This node is display-only — no workflow execution needed.
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
LoraInfoLM.NAME: LoraInfoLM,
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
LoraInfoLM.NAME: "Lora Info (LoraManager)",
|
||||||
|
}
|
||||||
+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"],
|
||||||
|
|||||||
@@ -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),)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -573,12 +573,18 @@ class NodeRegistry:
|
|||||||
tab_nodes[nd["unique_id"]] = nd
|
tab_nodes[nd["unique_id"]] = nd
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
|
prev_count = len(self._tab_nodes.get(sid, {}))
|
||||||
self._tab_nodes[sid] = tab_nodes
|
self._tab_nodes[sid] = tab_nodes
|
||||||
self._waiting_clients.discard(sid)
|
self._waiting_clients.discard(sid)
|
||||||
if not self._waiting_clients:
|
if not self._waiting_clients:
|
||||||
self._ready.set()
|
self._ready.set()
|
||||||
|
total_tabs = len(self._tab_nodes)
|
||||||
|
|
||||||
logger.debug("Registered %s nodes from client %s", len(nodes), sid)
|
if len(nodes) != prev_count or len(nodes) > 0:
|
||||||
|
logger.debug(
|
||||||
|
"[LM:Registry] stored %s nodes (was %s) for client %s (total tabs: %s)",
|
||||||
|
len(nodes), prev_count, sid, total_tabs,
|
||||||
|
)
|
||||||
|
|
||||||
def prepare_for_refresh(self, active_sids: list[str]) -> None:
|
def prepare_for_refresh(self, active_sids: list[str]) -> None:
|
||||||
"""Set the list of client IDs we expect to hear from during the next refresh cycle."""
|
"""Set the list of client IDs we expect to hear from during the next refresh cycle."""
|
||||||
@@ -601,10 +607,17 @@ class NodeRegistry:
|
|||||||
longer connected."""
|
longer connected."""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
# Garbage-collect stale entries (disconnected tabs)
|
# Garbage-collect stale entries (disconnected tabs)
|
||||||
|
stale_sids = []
|
||||||
if active_sids is not None:
|
if active_sids is not None:
|
||||||
for sid in list(self._tab_nodes):
|
for sid in list(self._tab_nodes):
|
||||||
if sid not in active_sids:
|
if sid not in active_sids:
|
||||||
|
stale_sids.append(sid)
|
||||||
del self._tab_nodes[sid]
|
del self._tab_nodes[sid]
|
||||||
|
if stale_sids:
|
||||||
|
logger.debug(
|
||||||
|
"[LM:Registry] GC pruned %s disconnected tabs: %s",
|
||||||
|
len(stale_sids), stale_sids,
|
||||||
|
)
|
||||||
|
|
||||||
merged: dict[str, dict] = {}
|
merged: dict[str, dict] = {}
|
||||||
tab_info: dict[str, dict] = {}
|
tab_info: dict[str, dict] = {}
|
||||||
@@ -1557,7 +1570,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:
|
||||||
@@ -1771,6 +1788,124 @@ class LoraCodeHandler:
|
|||||||
logger.error("Failed to update lora code: %s", exc, exc_info=True)
|
logger.error("Failed to update lora code: %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 get_update_lora_code(self, request: web.Request) -> web.Response:
|
||||||
|
"""GET version of update_lora_code — reads parameters from query string.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
lora_code (required) — the LoRA syntax to send
|
||||||
|
mode (optional) — "append" (default) or "replace"
|
||||||
|
node_id (repeatable) — target node id(s), e.g. node_id=3&node_id=5
|
||||||
|
node_ids (optional) — JSON-encoded array for complex references with graph_id:
|
||||||
|
[{"node_id":3,"graph_id":"g1"}, ...]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
node_ids_raw = request.query.get("node_ids")
|
||||||
|
node_id_list = request.query.getall("node_id", [])
|
||||||
|
lora_code = request.query.get("lora_code", "")
|
||||||
|
mode = request.query.get("mode", "append")
|
||||||
|
|
||||||
|
if not lora_code:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing lora_code parameter"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
node_ids = None
|
||||||
|
if node_ids_raw:
|
||||||
|
try:
|
||||||
|
node_ids = json.loads(node_ids_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "node_ids must be a valid JSON array"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
if not isinstance(node_ids, list) or not node_ids:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "node_ids must be a non-empty JSON array"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
elif node_id_list:
|
||||||
|
node_ids = node_id_list
|
||||||
|
|
||||||
|
results = []
|
||||||
|
if node_ids is None:
|
||||||
|
try:
|
||||||
|
self._prompt_server.instance.send_sync(
|
||||||
|
"lora_code_update",
|
||||||
|
{"id": -1, "lora_code": lora_code, "mode": mode},
|
||||||
|
)
|
||||||
|
results.append({"node_id": "broadcast", "success": True})
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Error broadcasting lora code: %s", exc)
|
||||||
|
results.append(
|
||||||
|
{"node_id": "broadcast", "success": False, "error": str(exc)}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for entry in node_ids:
|
||||||
|
node_identifier = entry
|
||||||
|
graph_identifier = None
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
node_identifier = entry.get("node_id")
|
||||||
|
graph_identifier = entry.get("graph_id")
|
||||||
|
|
||||||
|
if node_identifier is None:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"node_id": node_identifier,
|
||||||
|
"graph_id": graph_identifier,
|
||||||
|
"success": False,
|
||||||
|
"error": "Missing node_id parameter",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_node_id = int(node_identifier)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
parsed_node_id = node_identifier
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"id": parsed_node_id,
|
||||||
|
"lora_code": lora_code,
|
||||||
|
"mode": mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
if graph_identifier is not None:
|
||||||
|
payload["graph_id"] = str(graph_identifier)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._prompt_server.instance.send_sync(
|
||||||
|
"lora_code_update",
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"node_id": parsed_node_id,
|
||||||
|
"graph_id": payload.get("graph_id"),
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Error sending lora code to node %s (graph %s): %s",
|
||||||
|
parsed_node_id,
|
||||||
|
graph_identifier,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"node_id": parsed_node_id,
|
||||||
|
"graph_id": payload.get("graph_id"),
|
||||||
|
"success": False,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response({"success": True, "results": results})
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Failed to update lora code (GET): %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class TrainedWordsHandler:
|
class TrainedWordsHandler:
|
||||||
async def get_trained_words(self, request: web.Request) -> web.Response:
|
async def get_trained_words(self, request: web.Request) -> web.Response:
|
||||||
@@ -3116,6 +3251,8 @@ class NodeRegistryHandler:
|
|||||||
self._node_registry = node_registry
|
self._node_registry = node_registry
|
||||||
self._prompt_server = prompt_server
|
self._prompt_server = prompt_server
|
||||||
self._standalone_mode = standalone_mode
|
self._standalone_mode = standalone_mode
|
||||||
|
self._refresh_lock = asyncio.Lock()
|
||||||
|
self._last_slow_path_ts: float = 0.0
|
||||||
|
|
||||||
async def register_nodes(self, request: web.Request) -> web.Response:
|
async def register_nodes(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
@@ -3162,6 +3299,11 @@ class NodeRegistryHandler:
|
|||||||
)
|
)
|
||||||
graph_name = node.get("graph_name")
|
graph_name = node.get("graph_name")
|
||||||
try:
|
try:
|
||||||
|
# Handle compound node IDs from expanded group subgraphs,
|
||||||
|
# e.g. "252:0" → 0 (parent scope is already in graph_id)
|
||||||
|
if isinstance(node_id, str) and ":" in node_id:
|
||||||
|
node["node_id"] = int(node_id.rsplit(":", 1)[-1])
|
||||||
|
else:
|
||||||
node["node_id"] = int(node_id)
|
node["node_id"] = int(node_id)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -3203,8 +3345,63 @@ class NodeRegistryHandler:
|
|||||||
status=503,
|
status=503,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Snapshot of currently-connected ComfyUI tabs
|
current_sids = set(self._prompt_server.instance.sockets.keys())
|
||||||
active_sids = list(self._prompt_server.instance.sockets.keys())
|
|
||||||
|
# Fast path: if the frontend has already pushed node data (via
|
||||||
|
# afterConfigureGraph / graphChanged hooks), return it immediately
|
||||||
|
# without triggering a WebSocket round-trip.
|
||||||
|
registry_info = await self._node_registry.get_merged_registry(
|
||||||
|
active_sids=current_sids
|
||||||
|
)
|
||||||
|
if registry_info["tab_count"] > 0:
|
||||||
|
logger.debug(
|
||||||
|
"[LM:Registry] fast path: %s nodes across %s tabs %s",
|
||||||
|
registry_info["node_count"],
|
||||||
|
registry_info["tab_count"],
|
||||||
|
dict(registry_info.get("tabs", {})),
|
||||||
|
)
|
||||||
|
return web.json_response({"success": True, "data": registry_info})
|
||||||
|
|
||||||
|
# Slow path: registry is empty — trigger refresh via WebSocket.
|
||||||
|
# Serialize with an async lock so concurrent callers don't all
|
||||||
|
# trigger separate WS refresh cycles. The second caller will
|
||||||
|
# re-check the fast path and (usually) find populated data.
|
||||||
|
async with self._refresh_lock:
|
||||||
|
# Re-check after acquiring the lock — another concurrent call
|
||||||
|
# may have populated the cache while we were waiting.
|
||||||
|
registry_info = await self._node_registry.get_merged_registry(
|
||||||
|
active_sids=current_sids
|
||||||
|
)
|
||||||
|
if registry_info["tab_count"] > 0:
|
||||||
|
logger.debug(
|
||||||
|
"[LM:Registry] fast path after lock wait: %s nodes across %s tabs",
|
||||||
|
registry_info["node_count"],
|
||||||
|
registry_info["tab_count"],
|
||||||
|
)
|
||||||
|
return web.json_response({"success": True, "data": registry_info})
|
||||||
|
|
||||||
|
# Cooldown: if the slow path ran recently (< 2 s) and
|
||||||
|
# returned empty, skip another WS round-trip.
|
||||||
|
elapsed = time.monotonic() - self._last_slow_path_ts
|
||||||
|
if elapsed < 2.0:
|
||||||
|
logger.debug(
|
||||||
|
"[LM:Registry] slow path cooldown (%.1fs since last refresh), returning empty",
|
||||||
|
elapsed,
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Empty Registry",
|
||||||
|
"message": "No workflow nodes found — ensure ComfyUI is open and the extension is loaded.",
|
||||||
|
},
|
||||||
|
status=408,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"[LM:Registry] slow path: cache empty, triggering WS refresh (%s connected tabs: %s)",
|
||||||
|
len(current_sids), list(current_sids)[:5],
|
||||||
|
)
|
||||||
|
active_sids = list(current_sids)
|
||||||
self._node_registry.prepare_for_refresh(active_sids)
|
self._node_registry.prepare_for_refresh(active_sids)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -3223,9 +3420,9 @@ class NodeRegistryHandler:
|
|||||||
status=500,
|
status=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not await self._node_registry.wait_for_all(timeout=2.0):
|
if not await self._node_registry.wait_for_all(timeout=0.5):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Registry refresh timeout after 2s (%s/%s clients responded)",
|
"Registry refresh timeout after 0.5s (%s/%s clients responded)",
|
||||||
len(active_sids) - self._node_registry.pending_client_count,
|
len(active_sids) - self._node_registry.pending_client_count,
|
||||||
len(active_sids),
|
len(active_sids),
|
||||||
)
|
)
|
||||||
@@ -3236,9 +3433,13 @@ class NodeRegistryHandler:
|
|||||||
registry_info = await self._node_registry.get_merged_registry(
|
registry_info = await self._node_registry.get_merged_registry(
|
||||||
active_sids=current_sids
|
active_sids=current_sids
|
||||||
)
|
)
|
||||||
|
self._last_slow_path_ts = time.monotonic()
|
||||||
|
|
||||||
if registry_info["node_count"] == 0:
|
if registry_info["node_count"] == 0:
|
||||||
logger.warning("No nodes registered after refresh")
|
logger.debug(
|
||||||
|
"[LM:Registry] refresh OK — %s connected tab(s) but 0 compatible nodes found",
|
||||||
|
registry_info["tab_count"],
|
||||||
|
)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -3274,7 +3475,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
|
||||||
)
|
)
|
||||||
@@ -3352,6 +3553,130 @@ class NodeRegistryHandler:
|
|||||||
logger.error("Failed to update node widget: %s", exc, exc_info=True)
|
logger.error("Failed to update node widget: %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 get_update_node_widget(self, request: web.Request) -> web.Response:
|
||||||
|
"""GET version of update_node_widget — reads parameters from query string.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
widget_name (optional) — the widget name to update (required unless action is set)
|
||||||
|
action (optional) — alternative action, e.g. "inject_text" (required unless widget_name is set)
|
||||||
|
value (required) — the value to set
|
||||||
|
mode (optional) — "replace" (default) or "append"
|
||||||
|
node_id (repeatable) — target node id(s), e.g. node_id=3&node_id=5
|
||||||
|
node_ids (optional) — JSON-encoded array for complex references:
|
||||||
|
[{"node_id":3,"graph_id":"g1"}, ...]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
widget_name = request.query.get("widget_name")
|
||||||
|
action = request.query.get("action")
|
||||||
|
value = request.query.get("value")
|
||||||
|
mode = request.query.get("mode", "replace")
|
||||||
|
node_ids_raw = request.query.get("node_ids")
|
||||||
|
node_id_list = request.query.getall("node_id", [])
|
||||||
|
|
||||||
|
if not action and (not isinstance(widget_name, str) or not widget_name):
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Missing parameter: provide either 'action' or 'widget_name'",
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
if value is None or (isinstance(value, str) and not value):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing value parameter"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
node_ids = None
|
||||||
|
if node_ids_raw:
|
||||||
|
try:
|
||||||
|
node_ids = json.loads(node_ids_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "node_ids must be a valid JSON array"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
if not isinstance(node_ids, list) or not node_ids:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "node_ids must be a non-empty JSON array"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
elif node_id_list:
|
||||||
|
node_ids = node_id_list
|
||||||
|
|
||||||
|
if not isinstance(node_ids, list) or not node_ids:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "node_ids must be a non-empty list"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for entry in node_ids:
|
||||||
|
node_identifier = entry
|
||||||
|
graph_identifier = None
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
node_identifier = entry.get("node_id")
|
||||||
|
graph_identifier = entry.get("graph_id")
|
||||||
|
|
||||||
|
if node_identifier is None:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"node_id": node_identifier,
|
||||||
|
"graph_id": graph_identifier,
|
||||||
|
"success": False,
|
||||||
|
"error": "Missing node_id parameter",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_node_id = int(node_identifier)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
parsed_node_id = node_identifier
|
||||||
|
|
||||||
|
payload: dict = {
|
||||||
|
"id": parsed_node_id,
|
||||||
|
"value": value,
|
||||||
|
"mode": mode,
|
||||||
|
}
|
||||||
|
if action:
|
||||||
|
payload["action"] = action
|
||||||
|
if widget_name:
|
||||||
|
payload["widget_name"] = widget_name
|
||||||
|
|
||||||
|
if graph_identifier is not None:
|
||||||
|
payload["graph_id"] = str(graph_identifier)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._prompt_server.instance.send_sync("lm_widget_update", payload)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"node_id": parsed_node_id,
|
||||||
|
"graph_id": payload.get("graph_id"),
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Error sending widget update to node %s (graph %s): %s",
|
||||||
|
parsed_node_id,
|
||||||
|
graph_identifier,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"node_id": parsed_node_id,
|
||||||
|
"graph_id": payload.get("graph_id"),
|
||||||
|
"success": False,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response({"success": True, "results": results})
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Failed to update node widget (GET): %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class MiscHandlerSet:
|
class MiscHandlerSet:
|
||||||
"""Aggregate handlers into a lookup compatible with the registrar."""
|
"""Aggregate handlers into a lookup compatible with the registrar."""
|
||||||
@@ -3418,10 +3743,12 @@ class MiscHandlerSet:
|
|||||||
"update_usage_stats": self.usage_stats.update_usage_stats,
|
"update_usage_stats": self.usage_stats.update_usage_stats,
|
||||||
"get_usage_stats": self.usage_stats.get_usage_stats,
|
"get_usage_stats": self.usage_stats.get_usage_stats,
|
||||||
"update_lora_code": self.lora_code.update_lora_code,
|
"update_lora_code": self.lora_code.update_lora_code,
|
||||||
|
"get_update_lora_code": self.lora_code.get_update_lora_code,
|
||||||
"get_trained_words": self.trained_words.get_trained_words,
|
"get_trained_words": self.trained_words.get_trained_words,
|
||||||
"get_model_example_files": self.model_examples.get_model_example_files,
|
"get_model_example_files": self.model_examples.get_model_example_files,
|
||||||
"register_nodes": self.node_registry.register_nodes,
|
"register_nodes": self.node_registry.register_nodes,
|
||||||
"update_node_widget": self.node_registry.update_node_widget,
|
"update_node_widget": self.node_registry.update_node_widget,
|
||||||
|
"get_update_node_widget": self.node_registry.get_update_node_widget,
|
||||||
"get_registry": self.node_registry.get_registry,
|
"get_registry": self.node_registry.get_registry,
|
||||||
"check_model_exists": self.model_library.check_model_exists,
|
"check_model_exists": self.model_library.check_model_exists,
|
||||||
"check_models_exist": self.model_library.check_models_exist,
|
"check_models_exist": self.model_library.check_models_exist,
|
||||||
|
|||||||
@@ -973,6 +973,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 +983,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"))
|
||||||
@@ -1275,9 +1293,13 @@ class ModelQueryHandler:
|
|||||||
text=f"{self._service.model_type.capitalize()} file name is required",
|
text=f"{self._service.model_type.capitalize()} file name is required",
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
notes = await self._service.get_model_notes(model_name)
|
result = await self._service.get_model_notes(model_name)
|
||||||
if notes is not None:
|
if result is not None:
|
||||||
return web.json_response({"success": True, "notes": notes})
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"notes": result["notes"],
|
||||||
|
"file_path": result["file_path"],
|
||||||
|
})
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -1313,6 +1335,17 @@ class ModelQueryHandler:
|
|||||||
}
|
}
|
||||||
if include_license_flags:
|
if include_license_flags:
|
||||||
model_data = await self._service.get_model_info_by_name(model_name)
|
model_data = await self._service.get_model_info_by_name(model_name)
|
||||||
|
# Only return license_flags when real CivitAI model license
|
||||||
|
# data exists. This mirrors ModelModal's guard
|
||||||
|
# (modelData?.civitai?.model) so the preview tooltip never
|
||||||
|
# shows misleading license icons for HF or other models
|
||||||
|
# without actual license metadata.
|
||||||
|
civitai_data = (model_data or {}).get("civitai") or {}
|
||||||
|
has_license_data = (
|
||||||
|
isinstance(civitai_data, dict)
|
||||||
|
and isinstance(civitai_data.get("model"), dict)
|
||||||
|
)
|
||||||
|
if has_license_data:
|
||||||
license_flags = (model_data or {}).get("license_flags")
|
license_flags = (model_data or {}).get("license_flags")
|
||||||
if license_flags is not None:
|
if license_flags is not None:
|
||||||
response_payload["license_flags"] = int(license_flags)
|
response_payload["license_flags"] = int(license_flags)
|
||||||
@@ -1772,14 +1805,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(
|
||||||
@@ -1789,14 +1828,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"},
|
||||||
@@ -2920,6 +2965,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()
|
||||||
|
|||||||
@@ -39,10 +39,12 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
||||||
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
||||||
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
|
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
|
||||||
|
RouteDefinition("GET", "/api/lm/update-lora-code", "get_update_lora_code"),
|
||||||
RouteDefinition("GET", "/api/lm/trained-words", "get_trained_words"),
|
RouteDefinition("GET", "/api/lm/trained-words", "get_trained_words"),
|
||||||
RouteDefinition("GET", "/api/lm/model-example-files", "get_model_example_files"),
|
RouteDefinition("GET", "/api/lm/model-example-files", "get_model_example_files"),
|
||||||
RouteDefinition("POST", "/api/lm/register-nodes", "register_nodes"),
|
RouteDefinition("POST", "/api/lm/register-nodes", "register_nodes"),
|
||||||
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
||||||
|
RouteDefinition("GET", "/api/lm/update-node-widget", "get_update_node_widget"),
|
||||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||||
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
|
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -804,6 +804,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)
|
||||||
@@ -955,13 +961,21 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
return unified_tree
|
return unified_tree
|
||||||
|
|
||||||
async def get_model_notes(self, model_name: str) -> Optional[str]:
|
async def get_model_notes(self, model_name: str) -> Optional[dict]:
|
||||||
"""Get notes for a specific model file"""
|
"""Get notes and file_path for a specific model file.
|
||||||
|
|
||||||
|
Supports both simple names (``OWSMianne_ANIMA_V1``) and full-path
|
||||||
|
syntax (``Anima/character/OWSMianne_ANIMA_V1``).
|
||||||
|
"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
for model in cache.raw_data:
|
for model in cache.raw_data:
|
||||||
if model["file_name"] == model_name:
|
file_name = model.get("file_name", "")
|
||||||
return model.get("notes", "")
|
if file_name == model_name or model_name.endswith("/" + file_name) or model_name.endswith("\\" + file_name):
|
||||||
|
return {
|
||||||
|
"notes": model.get("notes", ""),
|
||||||
|
"file_path": model.get("file_path", ""),
|
||||||
|
}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1084,6 +1098,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
|
||||||
@@ -1101,6 +1120,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
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,12 @@ class DownloadManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with download result
|
Dict with download result
|
||||||
"""
|
"""
|
||||||
|
logger.debug(
|
||||||
|
"[download] download_from_civitai called: model_id=%s, model_version_id=%s, "
|
||||||
|
"source=%s, file_params=%s",
|
||||||
|
model_id, model_version_id, source, file_params,
|
||||||
|
)
|
||||||
|
|
||||||
# Validate that at least one identifier is provided
|
# Validate that at least one identifier is provided
|
||||||
if not model_id and not model_version_id:
|
if not model_id and not model_version_id:
|
||||||
return {
|
return {
|
||||||
@@ -250,6 +256,7 @@ class DownloadManager:
|
|||||||
"source": source,
|
"source": source,
|
||||||
"file_params": copy.deepcopy(file_params) if file_params is not None else None,
|
"file_params": copy.deepcopy(file_params) if file_params is not None else None,
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
|
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"transfer_backend": self._get_model_download_backend(),
|
"transfer_backend": self._get_model_download_backend(),
|
||||||
"bytes_downloaded": 0,
|
"bytes_downloaded": 0,
|
||||||
@@ -289,8 +296,8 @@ class DownloadManager:
|
|||||||
return result
|
return result
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": True,
|
||||||
"error": "Download was cancelled",
|
"cancelled": True,
|
||||||
"download_id": task_id,
|
"download_id": task_id,
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
@@ -675,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))
|
||||||
@@ -1421,14 +1431,35 @@ class DownloadManager:
|
|||||||
|
|
||||||
# If file_params is provided, try to find matching file
|
# If file_params is provided, try to find matching file
|
||||||
if file_params and model_version_id:
|
if file_params and model_version_id:
|
||||||
|
target_file_id = file_params.get("id")
|
||||||
target_type = file_params.get("type", "Model")
|
target_type = file_params.get("type", "Model")
|
||||||
target_format = file_params.get("format", "SafeTensor")
|
target_format = file_params.get("format")
|
||||||
target_size = file_params.get("size", "full")
|
target_size = file_params.get("size")
|
||||||
target_fp = file_params.get("fp")
|
target_fp = file_params.get("fp")
|
||||||
is_primary = file_params.get("isPrimary", False)
|
is_primary = file_params.get("isPrimary", False)
|
||||||
|
|
||||||
if is_primary:
|
logger.debug(
|
||||||
# Find primary file
|
"[download] file_params received: id=%s, type=%s, format=%s, size=%s, fp=%s, isPrimary=%s, "
|
||||||
|
"model_version_id=%s, total_files=%d",
|
||||||
|
target_file_id, target_type, target_format, target_size, target_fp, is_primary,
|
||||||
|
model_version_id, len(files),
|
||||||
|
)
|
||||||
|
|
||||||
|
if target_file_id:
|
||||||
|
target_id_str = str(target_file_id)
|
||||||
|
for f in files:
|
||||||
|
f_id = f.get("id")
|
||||||
|
if str(f_id) == target_id_str:
|
||||||
|
file_info = f
|
||||||
|
logger.debug(
|
||||||
|
"[download] MATCH by ID: id=%s name='%s'",
|
||||||
|
f_id, f.get("name"),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if not file_info:
|
||||||
|
logger.debug("[download] No file found with id=%s", target_file_id)
|
||||||
|
|
||||||
|
elif is_primary:
|
||||||
file_info = next(
|
file_info = next(
|
||||||
(
|
(
|
||||||
f
|
f
|
||||||
@@ -1439,28 +1470,41 @@ class DownloadManager:
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Match by metadata
|
# Lenient metadata match: only compare fields present on both sides
|
||||||
for f in files:
|
for f in files:
|
||||||
f_type = f.get("type", "")
|
f_type = f.get("type", "")
|
||||||
f_meta = f.get("metadata", {})
|
|
||||||
|
|
||||||
# Check type match
|
|
||||||
if f_type != target_type:
|
if f_type != target_type:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check metadata match
|
f_meta = f.get("metadata", {})
|
||||||
if f_meta.get("format") != target_format:
|
f_format = f_meta.get("format") or f.get("format")
|
||||||
|
f_size = f_meta.get("size") or f.get("size")
|
||||||
|
f_fp = f_meta.get("fp") or f.get("fp")
|
||||||
|
|
||||||
|
if target_format and f_format != target_format:
|
||||||
continue
|
continue
|
||||||
if f_meta.get("size") != target_size:
|
if target_size and f_size and f_size != target_size:
|
||||||
continue
|
continue
|
||||||
if target_fp and f_meta.get("fp") != target_fp:
|
if target_fp and f_fp and f_fp != target_fp:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
file_info = f
|
file_info = f
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if not file_info:
|
||||||
|
logger.debug(
|
||||||
|
"[download] No match found via file_params — falling back to primary file lookup",
|
||||||
|
)
|
||||||
|
elif not file_params:
|
||||||
|
logger.debug(
|
||||||
|
"[download] No file_params provided (null/None) — will use primary file lookup. "
|
||||||
|
"model_version_id=%s, total_files=%d",
|
||||||
|
model_version_id, len(files),
|
||||||
|
)
|
||||||
|
|
||||||
# Fallback to primary file if no match found
|
# Fallback to primary file if no match found
|
||||||
if not file_info:
|
if not file_info:
|
||||||
|
logger.debug("[download] Looking for primary file as fallback")
|
||||||
file_info = next(
|
file_info = next(
|
||||||
(
|
(
|
||||||
f
|
f
|
||||||
@@ -1469,38 +1513,18 @@ class DownloadManager:
|
|||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
if file_info:
|
||||||
|
logger.debug(
|
||||||
|
"[download] Fallback primary file selected: id=%s, name=%s",
|
||||||
|
file_info.get("id"), file_info.get("name"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug("[download] No primary file found in fallback lookup")
|
||||||
|
|
||||||
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"}
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ class DownloadQueueService:
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
|
||||||
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 IF NOT EXISTS idx_dh_download_id
|
||||||
|
ON download_history(download_id) WHERE download_id IS NOT NULL;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -154,13 +156,23 @@ class DownloadQueueService:
|
|||||||
"""Insert a new download into the queue.
|
"""Insert a new download into the queue.
|
||||||
|
|
||||||
Returns the inserted row as a dict (or an empty dict if the
|
Returns the inserted row as a dict (or an empty dict if the
|
||||||
download_id already exists).
|
download_id already exists in the queue or has a terminal
|
||||||
|
record in history).
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
file_params_json = json.dumps(file_params) if file_params is not None else None
|
file_params_json = json.dumps(file_params) if file_params is not None else None
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
|
|
||||||
|
# Reject download_ids that already have a terminal record in history.
|
||||||
|
history_row = conn.execute(
|
||||||
|
"SELECT 1 FROM download_history WHERE download_id = ? LIMIT 1",
|
||||||
|
(download_id,),
|
||||||
|
).fetchone()
|
||||||
|
if history_row is not None:
|
||||||
|
return {}
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO download_queue (
|
INSERT OR IGNORE INTO download_queue (
|
||||||
@@ -380,7 +392,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
|
||||||
@@ -537,17 +549,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
|
||||||
|
|
||||||
@@ -604,21 +626,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"])
|
||||||
@@ -650,7 +685,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:
|
||||||
|
|||||||
@@ -271,12 +271,16 @@ class LoraService(BaseModelService):
|
|||||||
return letters
|
return letters
|
||||||
|
|
||||||
async def get_lora_trigger_words(self, lora_name: str) -> List[str]:
|
async def get_lora_trigger_words(self, lora_name: str) -> List[str]:
|
||||||
"""Get trigger words for a specific LoRA file"""
|
"""Get trigger words for a specific LoRA file.
|
||||||
|
|
||||||
|
Supports both simple names and full-path syntax.
|
||||||
|
"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
for lora in cache.raw_data:
|
for lora in cache.raw_data:
|
||||||
if lora["file_name"] == lora_name:
|
file_name = lora.get("file_name", "")
|
||||||
civitai_data = lora.get("civitai", {})
|
if file_name == lora_name or lora_name.endswith("/" + file_name) or lora_name.endswith("\\" + file_name):
|
||||||
|
civitai_data = lora.get("civitai") or {}
|
||||||
return civitai_data.get("trainedWords", [])
|
return civitai_data.get("trainedWords", [])
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -227,6 +227,11 @@ class ModelScanner:
|
|||||||
|
|
||||||
entry: Dict[str, Any] = {
|
entry: Dict[str, Any] = {
|
||||||
'file_path': normalized_path,
|
'file_path': normalized_path,
|
||||||
|
# file_name is always stored WITHOUT extension (e.g. "OWSMianne_ANIMA_V1",
|
||||||
|
# not "OWSMianne_ANIMA_V1.safetensors"). All upstream population points
|
||||||
|
# (MetadataManager, from_civitai_info, download manager, etc.) strip the
|
||||||
|
# extension via os.path.splitext before writing. Code consuming this field
|
||||||
|
# should match against names that are likewise extension-free.
|
||||||
'file_name': get_value('file_name', '') or '',
|
'file_name': get_value('file_name', '') or '',
|
||||||
'model_name': get_value('model_name', '') or '',
|
'model_name': get_value('model_name', '') or '',
|
||||||
'folder': normalized_folder,
|
'folder': normalized_folder,
|
||||||
@@ -1561,6 +1566,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 != (None, 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())
|
||||||
@@ -1614,6 +1831,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()
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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": "",
|
||||||
@@ -630,12 +632,37 @@ class SettingsManager:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_path_set(paths: Iterable[str]) -> set[str]:
|
||||||
|
"""Normalize an iterable of paths for set-based overlap comparison.
|
||||||
|
|
||||||
|
Resolves symlinks via ``os.path.realpath`` when the path exists on disk,
|
||||||
|
then applies ``os.path.normcase`` + ``os.path.normpath`` for consistent
|
||||||
|
cross-platform comparison. Non-string / empty entries are skipped.
|
||||||
|
"""
|
||||||
|
result: set[str] = set()
|
||||||
|
for p in paths:
|
||||||
|
if not isinstance(p, str):
|
||||||
|
continue
|
||||||
|
stripped = p.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
if os.path.exists(stripped):
|
||||||
|
stripped = os.path.normpath(os.path.realpath(stripped))
|
||||||
|
result.add(os.path.normcase(stripped))
|
||||||
|
return result
|
||||||
|
|
||||||
def _validate_folder_paths(
|
def _validate_folder_paths(
|
||||||
self,
|
self,
|
||||||
library_name: str,
|
library_name: str,
|
||||||
folder_paths: Mapping[str, Iterable[str]],
|
folder_paths: Mapping[str, Iterable[str]],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Ensure folder paths do not overlap with other libraries."""
|
"""Ensure folder paths do not overlap with other libraries.
|
||||||
|
|
||||||
|
Also detects checkpoints ↔ unet path overlap within the same library
|
||||||
|
(including via symlink resolution), which is a configuration error since
|
||||||
|
these model types must use separate physical folders.
|
||||||
|
"""
|
||||||
libraries = self.settings.get("libraries", {})
|
libraries = self.settings.get("libraries", {})
|
||||||
normalized_new: Dict[str, Dict[str, str]] = {}
|
normalized_new: Dict[str, Dict[str, str]] = {}
|
||||||
for key, values in folder_paths.items():
|
for key, values in folder_paths.items():
|
||||||
@@ -673,6 +700,22 @@ class SettingsManager:
|
|||||||
f"Folder path(s) {collisions} already assigned to library '{other_name}'"
|
f"Folder path(s) {collisions} already assigned to library '{other_name}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Checkpoints ↔ unet overlap within the same library
|
||||||
|
ckpt_paths = folder_paths.get("checkpoints", []) or []
|
||||||
|
unet_paths = folder_paths.get("unet", []) or []
|
||||||
|
if ckpt_paths and unet_paths:
|
||||||
|
ckpt_real = self._normalize_path_set(ckpt_paths)
|
||||||
|
unet_real = self._normalize_path_set(unet_paths)
|
||||||
|
overlap = ckpt_real & unet_real
|
||||||
|
if overlap:
|
||||||
|
collisions = ", ".join(sorted(overlap))
|
||||||
|
raise ValueError(
|
||||||
|
f"Path(s) {collisions} are configured for both "
|
||||||
|
f"'checkpoints' and 'unet' (diffusion models). "
|
||||||
|
f"These model types must use separate physical folders. "
|
||||||
|
f"Please remove one of the conflicting entries."
|
||||||
|
)
|
||||||
|
|
||||||
def _update_active_library_entry(
|
def _update_active_library_entry(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -1547,8 +1590,12 @@ class SettingsManager:
|
|||||||
portable_switch_pending = True
|
portable_switch_pending = True
|
||||||
self._prepare_portable_switch(value)
|
self._prepare_portable_switch(value)
|
||||||
if key == "folder_paths" and isinstance(value, Mapping):
|
if key == "folder_paths" and isinstance(value, Mapping):
|
||||||
|
active_name = self.get_active_library_name()
|
||||||
|
self._validate_folder_paths(active_name, value)
|
||||||
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
|
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
|
||||||
elif key == "extra_folder_paths" and isinstance(value, Mapping):
|
elif key == "extra_folder_paths" and isinstance(value, Mapping):
|
||||||
|
active_name = self.get_active_library_name()
|
||||||
|
self._validate_folder_paths(active_name, value)
|
||||||
self._update_active_library_entry(extra_folder_paths=value) # type: ignore[arg-type]
|
self._update_active_library_entry(extra_folder_paths=value) # type: ignore[arg-type]
|
||||||
elif key == "default_lora_root":
|
elif key == "default_lora_root":
|
||||||
self._update_active_library_entry(default_lora_root=str(value))
|
self._update_active_library_entry(default_lora_root=str(value))
|
||||||
|
|||||||
@@ -113,6 +113,35 @@ def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str
|
|||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return legacy_folder
|
return legacy_folder
|
||||||
|
elif not os.path.exists(resolved_folder):
|
||||||
|
# Reverse migration: when consolidating from multi-library to
|
||||||
|
# single-library mode (e.g. after "default" was cleaned up), look
|
||||||
|
# for existing example images inside library-named subdirectories
|
||||||
|
# and bring them back to the root level.
|
||||||
|
root = get_example_images_root()
|
||||||
|
if root:
|
||||||
|
try:
|
||||||
|
for entry in os.listdir(root):
|
||||||
|
entry_path = os.path.join(root, entry)
|
||||||
|
if not os.path.isdir(entry_path):
|
||||||
|
continue
|
||||||
|
if is_hash_folder(entry) or entry == "_deleted":
|
||||||
|
continue
|
||||||
|
if not _library_folder_has_only_hash_dirs(entry_path):
|
||||||
|
continue
|
||||||
|
legacy = os.path.join(entry_path, normalized_hash)
|
||||||
|
if os.path.exists(legacy):
|
||||||
|
shutil.move(legacy, resolved_folder)
|
||||||
|
logger.info(
|
||||||
|
"Consolidated example images from '%s' to '%s'",
|
||||||
|
legacy, resolved_folder,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to consolidate example images during "
|
||||||
|
"library merge: %s", exc,
|
||||||
|
)
|
||||||
|
|
||||||
return resolved_folder
|
return resolved_folder
|
||||||
|
|
||||||
|
|||||||
+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.6"
|
version = "1.1.8"
|
||||||
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
|
||||||
|
|||||||
@@ -1562,6 +1562,29 @@ input:checked + .toggle-slider:before {
|
|||||||
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
|
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.extra-folder-path-row .path-controls .extra-folder-path-input.has-error {
|
||||||
|
border-color: var(--lora-error);
|
||||||
|
background-color: rgba(220, 53, 69, 0.08);
|
||||||
|
background-color: rgba(from var(--lora-error) r g b / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extra-folder-path-row .path-controls .extra-folder-path-input.has-error:focus {
|
||||||
|
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.15);
|
||||||
|
box-shadow: 0 0 0 2px rgba(from var(--lora-error) r g b / 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extra-folder-path-error {
|
||||||
|
color: var(--lora-error);
|
||||||
|
font-size: 0.8em;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extra-folder-path-error.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.extra-folder-path-row .path-controls .remove-path-btn {
|
.extra-folder-path-row .path-controls .remove-path-btn {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -93,6 +93,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`,
|
||||||
|
|||||||
@@ -112,6 +112,18 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancelDownload(downloadId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${DOWNLOAD_ENDPOINTS.cancelGet}?download_id=${encodeURIComponent(downloadId)}`
|
||||||
|
);
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cancelling download:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
|
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
|
||||||
const pageState = this.getPageState();
|
const pageState = this.getPageState();
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,6 +397,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 +495,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 +539,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 +556,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 +579,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 +679,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 } : {})
|
||||||
@@ -1659,13 +1665,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++;
|
||||||
|
|||||||
@@ -196,6 +196,17 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
let completedDownloads = 0;
|
let completedDownloads = 0;
|
||||||
let failedDownloads = 0;
|
let failedDownloads = 0;
|
||||||
let currentLoraProgress = 0;
|
let currentLoraProgress = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
loadingManager.showCancelButton(async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
cancelled = true;
|
||||||
|
try {
|
||||||
|
await this.loraApiClient.cancelDownload(batchDownloadId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Cancel request failed:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Set up WebSocket message handler
|
// Set up WebSocket message handler
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
@@ -207,6 +218,11 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.status === 'cancelled') {
|
||||||
|
cancelled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Process progress updates
|
// Process progress updates
|
||||||
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
||||||
currentLoraProgress = data.progress;
|
currentLoraProgress = data.progress;
|
||||||
@@ -249,6 +265,8 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
|
|
||||||
// Download each LoRA sequentially
|
// Download each LoRA sequentially
|
||||||
for (let i = 0; i < lorasToDownload.length; i++) {
|
for (let i = 0; i < lorasToDownload.length; i++) {
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
const lora = lorasToDownload[i];
|
const lora = lorasToDownload[i];
|
||||||
|
|
||||||
currentLoraProgress = 0;
|
currentLoraProgress = 0;
|
||||||
@@ -275,11 +293,13 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
modelId,
|
modelId,
|
||||||
versionId,
|
versionId,
|
||||||
loraRoot,
|
loraRoot,
|
||||||
'', // Empty relative path, use default paths
|
'',
|
||||||
useDefaultPaths,
|
useDefaultPaths,
|
||||||
batchDownloadId
|
batchDownloadId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
||||||
failedDownloads++;
|
failedDownloads++;
|
||||||
@@ -288,10 +308,12 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
updateProgress(100, completedDownloads, '');
|
updateProgress(100, completedDownloads, '');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
||||||
failedDownloads++;
|
failedDownloads++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Close WebSocket
|
// Close WebSocket
|
||||||
ws.close();
|
ws.close();
|
||||||
@@ -300,7 +322,10 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
loadingManager.hide();
|
loadingManager.hide();
|
||||||
|
|
||||||
// Show completion message
|
// Show completion message
|
||||||
if (failedDownloads === 0) {
|
if (cancelled) {
|
||||||
|
showToast('toast.downloads.downloadStopped', {}, 'info',
|
||||||
|
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
||||||
|
} else if (failedDownloads === 0) {
|
||||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||||
} else {
|
} else {
|
||||||
showToast('toast.loras.downloadPartialSuccess', {
|
showToast('toast.loras.downloadPartialSuccess', {
|
||||||
|
|||||||
@@ -728,14 +728,23 @@ export class DownloadManager {
|
|||||||
|
|
||||||
confirmFileSelection() {
|
confirmFileSelection() {
|
||||||
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
|
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
|
||||||
if (!selectedRadio) return;
|
if (!selectedRadio) {
|
||||||
|
console.warn('[download] confirmFileSelection: no radio button checked');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const version = this.currentVersion;
|
const version = this.currentVersion;
|
||||||
if (!version) return;
|
if (!version) {
|
||||||
|
console.warn('[download] confirmFileSelection: no currentVersion set');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
|
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
|
||||||
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
||||||
|
|
||||||
|
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o',
|
||||||
|
this.selectedFile?.id, this.selectedFile?.name, this.selectedFile?.type, this.selectedFile?.metadata);
|
||||||
|
|
||||||
document.getElementById('fileSelectionStep').style.display = 'none';
|
document.getElementById('fileSelectionStep').style.display = 'none';
|
||||||
document.getElementById('locationStep').style.display = 'block';
|
document.getElementById('locationStep').style.display = 'block';
|
||||||
this.proceedToLocationContent();
|
this.proceedToLocationContent();
|
||||||
@@ -872,16 +881,26 @@ export class DownloadManager {
|
|||||||
const displayName = versionName || `#${versionId}`;
|
const displayName = versionName || `#${versionId}`;
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let updateProgress = () => { };
|
let updateProgress = () => { };
|
||||||
|
let cancelled = false;
|
||||||
|
const downloadId = Date.now().toString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.loadingManager.restoreProgressBar();
|
this.loadingManager.restoreProgressBar();
|
||||||
updateProgress = this.loadingManager.showDownloadProgress(1);
|
updateProgress = this.loadingManager.showDownloadProgress(1);
|
||||||
updateProgress(0, 0, displayName);
|
updateProgress(0, 0, displayName);
|
||||||
|
|
||||||
const downloadId = Date.now().toString();
|
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||||
|
|
||||||
|
this.loadingManager.showCancelButton(async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
cancelled = true;
|
||||||
|
try {
|
||||||
|
await this.apiClient.cancelDownload(downloadId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Cancel request failed:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ws.onmessage = event => {
|
ws.onmessage = event => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
@@ -890,6 +909,12 @@ export class DownloadManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.status === 'cancelled') {
|
||||||
|
cancelled = true;
|
||||||
|
this.loadingManager.setStatus(translate('modals.download.status.cancelled', {}, 'Download cancelled'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.status === 'progress' && data.download_id === downloadId) {
|
if (data.status === 'progress' && data.download_id === downloadId) {
|
||||||
const metrics = {
|
const metrics = {
|
||||||
bytesDownloaded: data.bytes_downloaded,
|
bytesDownloaded: data.bytes_downloaded,
|
||||||
@@ -928,6 +953,10 @@ export class DownloadManager {
|
|||||||
fileParams
|
fileParams
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (response?.skipped) {
|
if (response?.skipped) {
|
||||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||||
updateProgress(100, 0, displayName);
|
updateProgress(100, 0, displayName);
|
||||||
@@ -968,8 +997,12 @@ export class DownloadManager {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (cancelled) {
|
||||||
|
console.log('Download cancelled by user:', downloadId);
|
||||||
|
} else {
|
||||||
console.error('Failed to download model version:', error);
|
console.error('Failed to download model version:', error);
|
||||||
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
@@ -989,16 +1022,33 @@ export class DownloadManager {
|
|||||||
const totalFiles = this.hfSelectedFiles.length;
|
const totalFiles = this.hfSelectedFiles.length;
|
||||||
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
|
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let currentDownloadId = null;
|
||||||
|
|
||||||
|
this.loadingManager.showCancelButton(async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
cancelled = true;
|
||||||
|
if (currentDownloadId) {
|
||||||
|
try {
|
||||||
|
await this.apiClient.cancelDownload(currentDownloadId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Cancel request failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let completedDownloads = 0;
|
let completedDownloads = 0;
|
||||||
for (let i = 0; i < totalFiles; i++) {
|
for (let i = 0; i < totalFiles; i++) {
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
const filename = this.hfSelectedFiles[i];
|
const filename = this.hfSelectedFiles[i];
|
||||||
updateProgress(0, completedDownloads, filename);
|
updateProgress(0, completedDownloads, filename);
|
||||||
this.loadingManager.setStatus(`Downloading ${filename}...`);
|
this.loadingManager.setStatus(`Downloading ${filename}...`);
|
||||||
|
|
||||||
const downloadId = Date.now().toString() + '_' + i;
|
currentDownloadId = Date.now().toString() + '_' + i;
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${currentDownloadId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
@@ -1006,12 +1056,13 @@ export class DownloadManager {
|
|||||||
ws.onerror = reject;
|
ws.onerror = reject;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Capture completed count at WS creation time so progress
|
|
||||||
// updates arriving after completedDownloads increments still
|
|
||||||
// show the correct "N / total" position.
|
|
||||||
const snapshotCompleted = completedDownloads;
|
const snapshotCompleted = completedDownloads;
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.status === 'cancelled') {
|
||||||
|
cancelled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.status === 'progress') {
|
if (data.status === 'progress') {
|
||||||
const metrics = {
|
const metrics = {
|
||||||
bytesDownloaded: data.bytes_downloaded,
|
bytesDownloaded: data.bytes_downloaded,
|
||||||
@@ -1029,9 +1080,11 @@ export class DownloadManager {
|
|||||||
modelRoot,
|
modelRoot,
|
||||||
relativePath: targetFolder,
|
relativePath: targetFolder,
|
||||||
useDefaultPaths,
|
useDefaultPaths,
|
||||||
download_id: downloadId,
|
download_id: currentDownloadId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
if (response?.success) {
|
if (response?.success) {
|
||||||
completedDownloads++;
|
completedDownloads++;
|
||||||
updateProgress(100, completedDownloads, filename);
|
updateProgress(100, completedDownloads, filename);
|
||||||
@@ -1041,13 +1094,19 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
showToast('toast.downloads.downloadStopped', {}, 'info',
|
||||||
|
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
||||||
|
} else {
|
||||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||||
// Reload page data — model is already in scanner cache via backend
|
}
|
||||||
await resetAndReload(true);
|
await resetAndReload(true);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
console.error('Failed to download HF model:', error);
|
console.error('Failed to download HF model:', error);
|
||||||
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
this.loadingManager.hide();
|
this.loadingManager.hide();
|
||||||
@@ -1426,12 +1485,23 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fileParams = this.selectedFile ? {
|
const fileParams = this.selectedFile ? {
|
||||||
|
id: this.selectedFile.id,
|
||||||
type: this.selectedFile.type || 'Model',
|
type: this.selectedFile.type || 'Model',
|
||||||
format: this.selectedFile.metadata?.format || 'SafeTensor',
|
format: this.selectedFile.metadata?.format || null,
|
||||||
size: this.selectedFile.metadata?.size || 'full',
|
size: this.selectedFile.metadata?.size || null,
|
||||||
fp: this.selectedFile.metadata?.fp,
|
fp: this.selectedFile.metadata?.fp || null,
|
||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
|
if (fileParams) {
|
||||||
|
console.log('[download] startDownload (single): fileParams built from selectedFile — id=%s, type=%s, format=%s, size=%s, fp=%s',
|
||||||
|
fileParams.id, fileParams.type, fileParams.format, fileParams.size, fileParams.fp);
|
||||||
|
} else {
|
||||||
|
console.log('[download] startDownload (single): this.selectedFile is null — no file selection, will download primary/default file. version=%s has %d files',
|
||||||
|
this.currentVersion?.id, (this.currentVersion?.files || []).length);
|
||||||
|
}
|
||||||
|
|
||||||
|
modalManager.closeModal('downloadModal');
|
||||||
|
|
||||||
return this.executeDownloadWithProgress({
|
return this.executeDownloadWithProgress({
|
||||||
modelId: this.modelId,
|
modelId: this.modelId,
|
||||||
versionId: this.currentVersion.id,
|
versionId: this.currentVersion.id,
|
||||||
@@ -1470,11 +1540,27 @@ export class DownloadManager {
|
|||||||
|
|
||||||
let completedDownloads = 0;
|
let completedDownloads = 0;
|
||||||
let failedDownloads = 0;
|
let failedDownloads = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
loadingManager.showCancelButton(async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
cancelled = true;
|
||||||
|
try {
|
||||||
|
await this.apiClient.cancelDownload(batchDownloadId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Cancel request failed:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.type === 'download_id') return;
|
if (data.type === 'download_id') return;
|
||||||
|
|
||||||
|
if (data.status === 'cancelled') {
|
||||||
|
cancelled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
|
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
|
||||||
const current = downloadItems[completedDownloads + failedDownloads];
|
const current = downloadItems[completedDownloads + failedDownloads];
|
||||||
const name = current?.selectedVersion?.name || current?.displayName || current?.filename || `#${completedDownloads + failedDownloads + 1}`;
|
const name = current?.selectedVersion?.name || current?.displayName || current?.filename || `#${completedDownloads + failedDownloads + 1}`;
|
||||||
@@ -1493,6 +1579,8 @@ export class DownloadManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (let i = 0; i < downloadItems.length; i++) {
|
for (let i = 0; i < downloadItems.length; i++) {
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
const item = downloadItems[i];
|
const item = downloadItems[i];
|
||||||
const name = item.displayName || item.filename || (item.selectedVersion?.name || `Model #${item.modelId}`);
|
const name = item.displayName || item.filename || (item.selectedVersion?.name || `Model #${item.modelId}`);
|
||||||
const isHf = item.source === 'huggingface';
|
const isHf = item.source === 'huggingface';
|
||||||
@@ -1503,7 +1591,6 @@ export class DownloadManager {
|
|||||||
try {
|
try {
|
||||||
let response;
|
let response;
|
||||||
if (isHf) {
|
if (isHf) {
|
||||||
// Per-file WebSocket for real-time progress
|
|
||||||
const downloadId = Date.now().toString() + '_hf_' + i;
|
const downloadId = Date.now().toString() + '_hf_' + i;
|
||||||
const wsHf = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
const wsHf = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||||
try {
|
try {
|
||||||
@@ -1537,6 +1624,8 @@ export class DownloadManager {
|
|||||||
wsHf.close();
|
wsHf.close();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
console.log('[download] batch download: fileParams NOT passed for modelId=%s, versionId=%s — backend will use primary file',
|
||||||
|
item.modelId, item.selectedVersion?.id);
|
||||||
response = await this.apiClient.downloadModel(
|
response = await this.apiClient.downloadModel(
|
||||||
item.modelId,
|
item.modelId,
|
||||||
item.selectedVersion.id,
|
item.selectedVersion.id,
|
||||||
@@ -1548,6 +1637,8 @@ export class DownloadManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
failedDownloads++;
|
failedDownloads++;
|
||||||
} else {
|
} else {
|
||||||
@@ -1555,15 +1646,20 @@ export class DownloadManager {
|
|||||||
updateProgress(100, completedDownloads, '');
|
updateProgress(100, completedDownloads, '');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
console.error(`Failed to download ${name}:`, err);
|
console.error(`Failed to download ${name}:`, err);
|
||||||
failedDownloads++;
|
failedDownloads++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
loadingManager.hide();
|
loadingManager.hide();
|
||||||
|
|
||||||
if (failedDownloads === 0) {
|
if (cancelled) {
|
||||||
|
showToast('toast.downloads.downloadStopped', {}, 'info',
|
||||||
|
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
||||||
|
} else if (failedDownloads === 0) {
|
||||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||||
} else {
|
} else {
|
||||||
showToast('toast.loras.downloadPartialSuccess', {
|
showToast('toast.loras.downloadPartialSuccess', {
|
||||||
@@ -1581,6 +1677,10 @@ export class DownloadManager {
|
|||||||
modelRoot = '',
|
modelRoot = '',
|
||||||
targetFolder = ''
|
targetFolder = ''
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
console.warn('[download] downloadVersionWithDefaults: NO fileParams will be sent — backend will always use primary file. '
|
||||||
|
+ 'modelType=%s, modelId=%s, versionId=%s, versionName="%s"',
|
||||||
|
modelType, modelId, versionId, versionName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.apiClient = getModelApiClient(modelType);
|
this.apiClient = getModelApiClient(modelType);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -721,6 +806,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 +826,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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -281,6 +281,10 @@ export class LoadingManager {
|
|||||||
// Initialize transfer stats with empty data
|
// Initialize transfer stats with empty data
|
||||||
updateTransferStats();
|
updateTransferStats();
|
||||||
|
|
||||||
|
if (this.cancelButton) {
|
||||||
|
this.loadingContent.appendChild(this.cancelButton);
|
||||||
|
}
|
||||||
|
|
||||||
// Return update function
|
// Return update function
|
||||||
return (currentProgress, currentIndex = 0, currentName = '', metrics = {}) => {
|
return (currentProgress, currentIndex = 0, currentName = '', metrics = {}) => {
|
||||||
// Update current item progress
|
// Update current item progress
|
||||||
|
|||||||
@@ -1693,13 +1693,15 @@ export class SettingsManager {
|
|||||||
<input type="text" class="extra-folder-path-input"
|
<input type="text" class="extra-folder-path-input"
|
||||||
placeholder="${translate('settings.extraFolderPaths.pathPlaceholder', {}, '/path/to/models')}" value="${path}"
|
placeholder="${translate('settings.extraFolderPaths.pathPlaceholder', {}, '/path/to/models')}" value="${path}"
|
||||||
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
|
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
|
||||||
|
onfocus="settingsManager.clearExtraFolderPathError(this)"
|
||||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||||
<button type="button" class="remove-path-btn"
|
<button type="button" class="remove-path-btn"
|
||||||
onclick="this.parentElement.parentElement.remove(); settingsManager.updateExtraFolderPaths('${modelType}')"
|
onclick="settingsManager.removeExtraFolderPathRow(this, '${modelType}')"
|
||||||
title="${translate('common.actions.delete', {}, 'Delete')}">
|
title="${translate('common.actions.delete', {}, 'Delete')}">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="extra-folder-path-error"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
@@ -1713,7 +1715,63 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearExtraFolderPathError(input) {
|
||||||
|
input.classList.remove('has-error');
|
||||||
|
const row = input.closest('.extra-folder-path-row');
|
||||||
|
if (row) {
|
||||||
|
const errEl = row.querySelector('.extra-folder-path-error');
|
||||||
|
if (errEl) {
|
||||||
|
errEl.classList.remove('visible');
|
||||||
|
errEl.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearAllExtraFolderPathErrors() {
|
||||||
|
document.querySelectorAll('.extra-folder-path-input.has-error').forEach((input) => {
|
||||||
|
input.classList.remove('has-error');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.extra-folder-path-error.visible').forEach((el) => {
|
||||||
|
el.classList.remove('visible');
|
||||||
|
el.textContent = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_markExtraFolderPathsError(modelType, overlappingPaths, showMessage = false) {
|
||||||
|
const container = document.getElementById(`extraFolderPaths-${modelType}`);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const inputs = container.querySelectorAll('.extra-folder-path-input');
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
const val = input.value.trim();
|
||||||
|
if (val && overlappingPaths.includes(val)) {
|
||||||
|
input.classList.add('has-error');
|
||||||
|
if (showMessage) {
|
||||||
|
const row = input.closest('.extra-folder-path-row');
|
||||||
|
if (row) {
|
||||||
|
const errEl = row.querySelector('.extra-folder-path-error');
|
||||||
|
if (errEl) {
|
||||||
|
errEl.textContent = translate('settings.extraFolderPaths.validation.checkpointUnetOverlapInline', {}, 'This path is also used for a different model type. Use separate folders for checkpoints and diffusion models.');
|
||||||
|
errEl.classList.add('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeExtraFolderPathRow(btn, modelType) {
|
||||||
|
const row = btn.closest('.extra-folder-path-row');
|
||||||
|
if (row) {
|
||||||
|
row.remove();
|
||||||
|
this.updateExtraFolderPaths(modelType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async updateExtraFolderPaths(changedModelType) {
|
async updateExtraFolderPaths(changedModelType) {
|
||||||
|
// Clear previous errors
|
||||||
|
this._clearAllExtraFolderPathErrors();
|
||||||
|
|
||||||
const extraFolderPaths = {};
|
const extraFolderPaths = {};
|
||||||
|
|
||||||
// Collect paths for all model types
|
// Collect paths for all model types
|
||||||
@@ -1734,6 +1792,32 @@ export class SettingsManager {
|
|||||||
extraFolderPaths[modelType] = paths;
|
extraFolderPaths[modelType] = paths;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Client-side pre-check: checkpoints and unet must not share the same path.
|
||||||
|
// Normalise paths to reduce false negatives vs the backend's realpath + normcase.
|
||||||
|
const normalise = (p) => p.replace(/[/\\]+$/, '').toLowerCase();
|
||||||
|
const ckptSet = new Set((extraFolderPaths.checkpoints || []).map(normalise));
|
||||||
|
const unetSet = new Set((extraFolderPaths.unet || []).map(normalise));
|
||||||
|
const ckptOverlap = (extraFolderPaths.checkpoints || []).filter(p => p && unetSet.has(normalise(p)));
|
||||||
|
const unetOverlap = (extraFolderPaths.unet || []).filter(p => p && ckptSet.has(normalise(p)));
|
||||||
|
const hasOverlap = ckptOverlap.length > 0 || unetOverlap.length > 0;
|
||||||
|
|
||||||
|
if (hasOverlap) {
|
||||||
|
// Error message only on the side the user just edited.
|
||||||
|
// The other side gets red border only (passive conflict indicator).
|
||||||
|
if (changedModelType === 'checkpoints') {
|
||||||
|
this._markExtraFolderPathsError('checkpoints', ckptOverlap, true);
|
||||||
|
this._markExtraFolderPathsError('unet', unetOverlap, false);
|
||||||
|
} else if (changedModelType === 'unet') {
|
||||||
|
this._markExtraFolderPathsError('unet', unetOverlap, true);
|
||||||
|
this._markExtraFolderPathsError('checkpoints', ckptOverlap, false);
|
||||||
|
} else {
|
||||||
|
// Pre-existing conflict from direct config edit — mark both without messages
|
||||||
|
this._markExtraFolderPathsError('checkpoints', ckptOverlap, false);
|
||||||
|
this._markExtraFolderPathsError('unet', unetOverlap, false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if paths have actually changed
|
// Check if paths have actually changed
|
||||||
const currentPaths = state.global.settings.extra_folder_paths || {};
|
const currentPaths = state.global.settings.extra_folder_paths || {};
|
||||||
const pathsChanged = JSON.stringify(currentPaths) !== JSON.stringify(extraFolderPaths);
|
const pathsChanged = JSON.stringify(currentPaths) !== JSON.stringify(extraFolderPaths);
|
||||||
@@ -2262,6 +2346,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) {
|
||||||
|
|||||||
@@ -168,6 +168,18 @@ export class DownloadManager {
|
|||||||
let failedDownloads = 0;
|
let failedDownloads = 0;
|
||||||
let accessFailures = 0;
|
let accessFailures = 0;
|
||||||
let currentLoraProgress = 0;
|
let currentLoraProgress = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
this.importManager.loadingManager.showCancelButton(async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
cancelled = true;
|
||||||
|
try {
|
||||||
|
const loraClient = getModelApiClient(MODEL_TYPES.LORA);
|
||||||
|
await loraClient.cancelDownload(batchDownloadId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Cancel request failed:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Set up progress tracking for current download
|
// Set up progress tracking for current download
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
@@ -179,6 +191,11 @@ export class DownloadManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.status === 'cancelled') {
|
||||||
|
cancelled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Process progress updates for our current active download
|
// Process progress updates for our current active download
|
||||||
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
||||||
// Update current LoRA progress
|
// Update current LoRA progress
|
||||||
@@ -221,6 +238,8 @@ export class DownloadManager {
|
|||||||
const useDefaultPaths = getStorageItem('use_default_path_loras', false);
|
const useDefaultPaths = getStorageItem('use_default_path_loras', false);
|
||||||
|
|
||||||
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
|
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
const lora = this.importManager.downloadableLoRAs[i];
|
const lora = this.importManager.downloadableLoRAs[i];
|
||||||
|
|
||||||
// Reset current LoRA progress for new download
|
// Reset current LoRA progress for new download
|
||||||
@@ -241,15 +260,13 @@ export class DownloadManager {
|
|||||||
batchDownloadId
|
batchDownloadId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (cancelled) break;
|
||||||
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
|
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
|
||||||
|
|
||||||
failedDownloads++;
|
failedDownloads++;
|
||||||
// Continue with next download
|
|
||||||
} else {
|
} else {
|
||||||
completedDownloads++;
|
completedDownloads++;
|
||||||
|
|
||||||
// Update progress to show completion of current LoRA
|
|
||||||
updateProgress(100, completedDownloads, '');
|
updateProgress(100, completedDownloads, '');
|
||||||
|
|
||||||
if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) {
|
if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) {
|
||||||
@@ -259,9 +276,10 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (downloadError) {
|
} catch (downloadError) {
|
||||||
|
if (!cancelled) {
|
||||||
console.error(`Error downloading LoRA ${lora.name}:`, downloadError);
|
console.error(`Error downloading LoRA ${lora.name}:`, downloadError);
|
||||||
failedDownloads++;
|
failedDownloads++;
|
||||||
// Continue with next download
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +287,10 @@ export class DownloadManager {
|
|||||||
ws.close();
|
ws.close();
|
||||||
|
|
||||||
// Show appropriate completion message based on results
|
// Show appropriate completion message based on results
|
||||||
if (failedDownloads === 0) {
|
if (cancelled) {
|
||||||
|
showToast('toast.downloads.downloadStopped', {}, 'info',
|
||||||
|
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
||||||
|
} else if (failedDownloads === 0) {
|
||||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||||
} else {
|
} else {
|
||||||
if (accessFailures > 0) {
|
if (accessFailures > 0) {
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -552,6 +555,8 @@ async function fetchWorkflowRegistry() {
|
|||||||
if (!registryData.success) {
|
if (!registryData.success) {
|
||||||
if (registryData.error === 'Standalone Mode Active') {
|
if (registryData.error === 'Standalone Mode Active') {
|
||||||
showToast('toast.general.cannotInteractStandalone', {}, 'warning');
|
showToast('toast.general.cannotInteractStandalone', {}, 'warning');
|
||||||
|
} else if (registryData.error === 'Empty Registry') {
|
||||||
|
showToast('uiHelpers.workflow.noSupportedNodes', {}, 'warning');
|
||||||
} else {
|
} else {
|
||||||
showToast('toast.general.failedWorkflowInfo', {}, 'error');
|
showToast('toast.general.failedWorkflowInfo', {}, 'error');
|
||||||
}
|
}
|
||||||
@@ -603,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,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;
|
||||||
@@ -679,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 =
|
||||||
@@ -693,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;
|
||||||
}
|
}
|
||||||
@@ -965,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;
|
||||||
@@ -993,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]]);
|
||||||
@@ -1134,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');
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -112,6 +112,10 @@
|
|||||||
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide" target="_blank">
|
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide" target="_blank">
|
||||||
Priority Tags Configuration Guide
|
Priority Tags Configuration Guide
|
||||||
<span class="new-content-badge inline">{{ t('help.documentation.newBadge') }}</span>
|
<span class="new-content-badge inline">{{ t('help.documentation.newBadge') }}</span>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/AI-Provider-Setup" target="_blank">
|
||||||
|
AI Provider Setup
|
||||||
|
<span class="new-content-badge inline">{{ t('help.documentation.newBadge') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
parseCombinedSamplerName,
|
parseCombinedSamplerName,
|
||||||
resolveSamplerScheduler,
|
resolveSamplerScheduler,
|
||||||
findMatchingWidgets,
|
findMatchingWidgets,
|
||||||
|
NODE_TYPE_WIDGET_OVERRIDES,
|
||||||
} from '../../../static/js/utils/genParamsMapper.js';
|
} from '../../../static/js/utils/genParamsMapper.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -204,9 +205,9 @@ describe('findMatchingWidgets', () => {
|
|||||||
|
|
||||||
it('matches seed to seed widget', () => {
|
it('matches seed to seed widget', () => {
|
||||||
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
||||||
expect(updates).toContainEqual({ widgetName: 'seed', value: '42' });
|
expect(updates).toContainEqual({ widgetName: 'seed', value: 42 });
|
||||||
expect(updates).toContainEqual({ widgetName: 'steps', value: '30' });
|
expect(updates).toContainEqual({ widgetName: 'steps', value: 30 });
|
||||||
expect(updates).toContainEqual({ widgetName: 'cfg', value: '7' });
|
expect(updates).toContainEqual({ widgetName: 'cfg', value: 7 });
|
||||||
expect(updates).toContainEqual({ widgetName: 'sampler_name', value: 'euler_ancestral' });
|
expect(updates).toContainEqual({ widgetName: 'sampler_name', value: 'euler_ancestral' });
|
||||||
expect(updates).toContainEqual({ widgetName: 'scheduler', value: 'karras' });
|
expect(updates).toContainEqual({ widgetName: 'scheduler', value: 'karras' });
|
||||||
});
|
});
|
||||||
@@ -221,7 +222,7 @@ describe('findMatchingWidgets', () => {
|
|||||||
const updates = findMatchingWidgets(['noise_seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
const updates = findMatchingWidgets(['noise_seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
||||||
const seedUpdate = updates.find(u => u.widgetName === 'noise_seed');
|
const seedUpdate = updates.find(u => u.widgetName === 'noise_seed');
|
||||||
expect(seedUpdate).toBeDefined();
|
expect(seedUpdate).toBeDefined();
|
||||||
expect(seedUpdate.value).toBe('42');
|
expect(seedUpdate.value).toBe(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches rgthree-style sampler widget name', () => {
|
it('matches rgthree-style sampler widget name', () => {
|
||||||
@@ -243,4 +244,53 @@ describe('findMatchingWidgets', () => {
|
|||||||
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
||||||
expect(updates.map(u => u.widgetName)).toEqual(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler']);
|
expect(updates.map(u => u.widgetName)).toEqual(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- node-type-specific overrides ---
|
||||||
|
it('matches GlobalSeed //Inspire value widget for seed param', () => {
|
||||||
|
const updates = findMatchingWidgets(
|
||||||
|
['value', 'mode', 'action', 'last_seed'],
|
||||||
|
{ seed: 42 },
|
||||||
|
'GlobalSeed //Inspire'
|
||||||
|
);
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0]).toEqual({ widgetName: 'value', value: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores nodeType when it does not match any override entry', () => {
|
||||||
|
const updates = findMatchingWidgets(
|
||||||
|
['value', 'mode', 'action', 'last_seed'],
|
||||||
|
{ seed: 42 },
|
||||||
|
'SomeOtherNode'
|
||||||
|
);
|
||||||
|
expect(updates).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still falls back to global candidates when override candidates do not match', () => {
|
||||||
|
// GlobalSeed override does not include steps — should use global candidate "steps"
|
||||||
|
const updates = findMatchingWidgets(
|
||||||
|
['steps', 'cfg', 'sampler_name'],
|
||||||
|
{ steps: 20 },
|
||||||
|
'GlobalSeed //Inspire'
|
||||||
|
);
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0]).toEqual({ widgetName: 'steps', value: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers overrides when both override and global candidates match', () => {
|
||||||
|
// If a hypothetical node has both "value" and "seed" widgets AND a
|
||||||
|
// GlobalSeed override, the override candidate "value" should take precedence
|
||||||
|
const updates = findMatchingWidgets(
|
||||||
|
['seed', 'noise_seed', 'value', 'mode'],
|
||||||
|
{ seed: 99 },
|
||||||
|
'GlobalSeed //Inspire'
|
||||||
|
);
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0].widgetName).toBe('value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits nodeType argument and still matches via global candidates', () => {
|
||||||
|
const updates = findMatchingWidgets(['seed', 'steps', 'cfg'], { seed: 7 });
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0]).toEqual({ widgetName: 'seed', value: 7 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -728,6 +728,54 @@ async def test_register_nodes_includes_capabilities():
|
|||||||
assert stored_node["widget_names"] == ["ckpt_name"]
|
assert stored_node["widget_names"] == ["ckpt_name"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_nodes_accepts_compound_node_ids():
|
||||||
|
"""Subgraph nodes from expanded group nodes have compound IDs like '252:0'."""
|
||||||
|
node_registry = NodeRegistry()
|
||||||
|
handler = NodeRegistryHandler(
|
||||||
|
node_registry=node_registry,
|
||||||
|
prompt_server=FakePromptServer,
|
||||||
|
standalone_mode=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = FakeRequest(
|
||||||
|
json_data={
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"node_id": "252:0",
|
||||||
|
"graph_id": "252",
|
||||||
|
"type": "CheckpointLoaderSimple",
|
||||||
|
"title": "Checkpoint Loader (subgraph)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node_id": "252:1",
|
||||||
|
"graph_id": "252",
|
||||||
|
"type": "CLIPLoader",
|
||||||
|
"title": "CLIP Loader (subgraph)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"client_id": "test-client-1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.register_nodes(request)
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert "2 nodes registered" in payload["message"]
|
||||||
|
|
||||||
|
registry = await node_registry.get_merged_registry()
|
||||||
|
assert registry["node_count"] == 2
|
||||||
|
|
||||||
|
nodes_map = registry["nodes"]
|
||||||
|
assert "252:0" in nodes_map
|
||||||
|
assert "252:1" in nodes_map
|
||||||
|
assert nodes_map["252:0"]["id"] == 0
|
||||||
|
assert nodes_map["252:0"]["graph_id"] == "252"
|
||||||
|
assert nodes_map["252:1"]["id"] == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_node_widget_sends_payload():
|
async def test_update_node_widget_sends_payload():
|
||||||
send_calls: list[tuple[str, dict]] = []
|
send_calls: list[tuple[str, dict]] = []
|
||||||
|
|||||||
@@ -36,3 +36,53 @@ async def test_model_query_handler_rejects_negative_limit_for_base_models():
|
|||||||
await handler.get_base_models(SimpleNamespace(query={"limit": "-1"}))
|
await handler.get_base_models(SimpleNamespace(query={"limit": "-1"}))
|
||||||
|
|
||||||
assert service.received_limit == 20
|
assert service.received_limit == 20
|
||||||
|
|
||||||
|
|
||||||
|
class DummySearchTagsService:
|
||||||
|
"""Minimal service stub recording search_tags arguments."""
|
||||||
|
|
||||||
|
def __init__(self, result=None):
|
||||||
|
self.received_query = None
|
||||||
|
self.received_limit = None
|
||||||
|
self._result = result or []
|
||||||
|
|
||||||
|
async def search_tags(self, query, limit):
|
||||||
|
self.received_query = query
|
||||||
|
self.received_limit = limit
|
||||||
|
return self._result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_query_handler_search_tags_passes_query_and_limit():
|
||||||
|
service = DummySearchTagsService(result=[{"tag": "anime", "count": 3}])
|
||||||
|
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||||
|
|
||||||
|
response = await handler.search_tags(
|
||||||
|
SimpleNamespace(query={"q": "ani", "limit": "50"})
|
||||||
|
)
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["tags"] == [{"tag": "anime", "count": 3}]
|
||||||
|
assert service.received_query == "ani"
|
||||||
|
assert service.received_limit == 50
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_query_handler_search_tags_defaults_limit_to_20():
|
||||||
|
service = DummySearchTagsService()
|
||||||
|
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||||
|
|
||||||
|
await handler.search_tags(SimpleNamespace(query={}))
|
||||||
|
|
||||||
|
assert service.received_limit == 20
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_query_handler_search_tags_clamps_negative_limit():
|
||||||
|
service = DummySearchTagsService()
|
||||||
|
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||||
|
|
||||||
|
await handler.search_tags(SimpleNamespace(query={"limit": "-5"}))
|
||||||
|
|
||||||
|
assert service.received_limit == 20
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Unit tests for DownloadQueueService history operations.
|
||||||
|
|
||||||
|
Covers the new ``download_id``-based code paths in
|
||||||
|
``delete_history_item`` and ``retry_from_history``, plus backward
|
||||||
|
compatibility with ``id``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.services.download_queue_service import DownloadQueueService
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_service(tmp_path: Path) -> DownloadQueueService:
|
||||||
|
"""Create a DownloadQueueService backed by a temporary database."""
|
||||||
|
return DownloadQueueService(db_path=str(tmp_path / "queue.sqlite"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed(
|
||||||
|
svc: DownloadQueueService,
|
||||||
|
download_id: str,
|
||||||
|
status: str = "failed",
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""Insert a history row and return (autoincrement id, download_id)."""
|
||||||
|
row_id = await svc.add_to_history(
|
||||||
|
download_id=download_id,
|
||||||
|
model_id=1,
|
||||||
|
model_version_id=100,
|
||||||
|
model_name="TestModel",
|
||||||
|
version_name="v1",
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
return row_id, download_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# delete_history_item
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_by_download_id(tmp_path: Path) -> None:
|
||||||
|
"""delete_history_item(download_id=...) removes the correct row."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, did = await _seed(svc, "dl-aaa")
|
||||||
|
|
||||||
|
deleted = await svc.delete_history_item(download_id=did)
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
# Verify gone from history
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_by_id_legacy(tmp_path: Path) -> None:
|
||||||
|
"""delete_history_item(id=...) still works (backward compat)."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, _did = await _seed(svc, "dl-bbb")
|
||||||
|
|
||||||
|
deleted = await svc.delete_history_item(id=rid)
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_no_params_returns_false(tmp_path: Path) -> None:
|
||||||
|
"""Calling delete_history_item with no params returns False."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
await _seed(svc, "dl-ccc")
|
||||||
|
|
||||||
|
deleted = await svc.delete_history_item()
|
||||||
|
assert deleted is False
|
||||||
|
|
||||||
|
# Row is still there
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_download_id_precedence(tmp_path: Path) -> None:
|
||||||
|
"""When both id and download_id are given, download_id is used."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
# Insert two rows
|
||||||
|
rid_a, did_a = await _seed(svc, "dl-aaa")
|
||||||
|
rid_b, did_b = await _seed(svc, "dl-bbb")
|
||||||
|
|
||||||
|
# Delete by download_id while also passing the *wrong* id
|
||||||
|
deleted = await svc.delete_history_item(id=rid_b, download_id=did_a)
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
history = await svc.get_history()
|
||||||
|
ids_left = [it["id"] for it in history["items"]]
|
||||||
|
assert rid_a not in ids_left # dl-aaa was deleted
|
||||||
|
assert rid_b in ids_left # dl-bbb (wrong id) was ignored
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# retry_from_history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_by_download_id(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history(download_id=...) re-queues and deletes history."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, did = await _seed(svc, "dl-fail", status="failed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(download_id=did)
|
||||||
|
assert item is not None
|
||||||
|
assert item["status"] == "queued"
|
||||||
|
|
||||||
|
# History row must be deleted (the bug fix)
|
||||||
|
history = await svc.get_history()
|
||||||
|
ids_in_history = [it["id"] for it in history["items"]]
|
||||||
|
assert rid not in ids_in_history
|
||||||
|
|
||||||
|
# Queue must contain the new item
|
||||||
|
queue = await svc.get_queue()
|
||||||
|
assert len(queue) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_by_download_id_canceled(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history works for 'canceled' status too."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, did = await _seed(svc, "dl-cancel", status="canceled")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(download_id=did)
|
||||||
|
assert item is not None
|
||||||
|
assert item["status"] == "queued"
|
||||||
|
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_by_id_legacy(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history(item_id=...) still works (backward compat)."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, _did = await _seed(svc, "dl-legacy", status="failed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(item_id=rid)
|
||||||
|
assert item is not None
|
||||||
|
assert item["status"] == "queued"
|
||||||
|
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_no_params_returns_none(tmp_path: Path) -> None:
|
||||||
|
"""Calling retry_from_history with no params returns None."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
await _seed(svc, "dl-none", status="failed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history()
|
||||||
|
assert item is None
|
||||||
|
|
||||||
|
# History untouched
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_non_retryable_status(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history returns None for 'completed' status."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
_rid, did = await _seed(svc, "dl-ok", status="completed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(download_id=did)
|
||||||
|
assert item is None
|
||||||
|
|
||||||
|
# History untouched
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_unknown_download_id(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history returns None for a non-existent download_id."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
await _seed(svc, "dl-real", status="failed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(download_id="dl-nope")
|
||||||
|
assert item is None
|
||||||
@@ -60,3 +60,114 @@ async def test_get_metadata_provider_returns_fallback_as_is(monkeypatch):
|
|||||||
provider = await metadata_service.get_metadata_provider()
|
provider = await metadata_service.get_metadata_provider()
|
||||||
|
|
||||||
assert provider is fallback
|
assert provider is fallback
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# initialize_metadata_providers — provider gating + fallback ordering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_settings(**overrides):
|
||||||
|
"""Minimal settings stub returning configured values."""
|
||||||
|
base = {
|
||||||
|
"enable_metadata_archive_db": False,
|
||||||
|
"enable_civarchive_api": True,
|
||||||
|
"metadata_provider_order": "civitai_archive_sqlite",
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return SimpleNamespace(get=lambda key, default=None: base.get(key, default))
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_initialize(monkeypatch, settings):
|
||||||
|
# Fresh provider manager for each test
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metadata_service.ModelMetadataProviderManager,
|
||||||
|
"get_instance",
|
||||||
|
AsyncMock(return_value=metadata_service.ModelMetadataProviderManager()),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metadata_service, "get_settings_manager", lambda: settings
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metadata_service.ServiceRegistry,
|
||||||
|
"get_civitai_client",
|
||||||
|
AsyncMock(return_value=object()),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metadata_service.ServiceRegistry,
|
||||||
|
"get_civarchive_client",
|
||||||
|
AsyncMock(return_value=object()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make MetadataArchiveManager report a usable db path when enabled
|
||||||
|
fake_archive = SimpleNamespace(get_database_path=lambda: "/tmp/fake.db")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metadata_service, "MetadataArchiveManager", lambda _base: fake_archive
|
||||||
|
)
|
||||||
|
# Pretend the db file exists
|
||||||
|
monkeypatch.setattr(metadata_service.os.path, "exists", lambda _p: True)
|
||||||
|
|
||||||
|
manager = await metadata_service.initialize_metadata_providers()
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_provider_order(manager):
|
||||||
|
"""Return the ordered list of provider labels inside the fallback provider."""
|
||||||
|
fallback = manager.providers.get("fallback")
|
||||||
|
assert isinstance(fallback, FallbackMetadataProvider), "expected a fallback provider"
|
||||||
|
return list(fallback._provider_labels)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize_providers_default_order(monkeypatch):
|
||||||
|
settings = _stub_settings(enable_metadata_archive_db=True)
|
||||||
|
manager = await _run_initialize(monkeypatch, settings)
|
||||||
|
assert _fallback_provider_order(manager) == ["civitai_api", "civarchive_api", "sqlite"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize_providers_prefer_sqlite_order(monkeypatch):
|
||||||
|
settings = _stub_settings(
|
||||||
|
enable_metadata_archive_db=True,
|
||||||
|
metadata_provider_order="civitai_sqlite_archive",
|
||||||
|
)
|
||||||
|
manager = await _run_initialize(monkeypatch, settings)
|
||||||
|
assert _fallback_provider_order(manager) == ["civitai_api", "sqlite", "civarchive_api"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize_providers_disables_civarchive(monkeypatch):
|
||||||
|
settings = _stub_settings(
|
||||||
|
enable_metadata_archive_db=True,
|
||||||
|
enable_civarchive_api=False,
|
||||||
|
)
|
||||||
|
manager = await _run_initialize(monkeypatch, settings)
|
||||||
|
# civarchive_api must not be registered at all
|
||||||
|
assert "civarchive_api" not in manager.providers
|
||||||
|
assert _fallback_provider_order(manager) == ["civitai_api", "sqlite"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize_providers_skips_unavailable_sqlite_in_preset(monkeypatch):
|
||||||
|
# Preset wants sqlite before civarchive, but archive db is disabled ->
|
||||||
|
# sqlite is unavailable and must be skipped, civarchive stays.
|
||||||
|
settings = _stub_settings(
|
||||||
|
enable_metadata_archive_db=False,
|
||||||
|
metadata_provider_order="civitai_sqlite_archive",
|
||||||
|
)
|
||||||
|
manager = await _run_initialize(monkeypatch, settings)
|
||||||
|
assert _fallback_provider_order(manager) == ["civitai_api", "civarchive_api"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize_providers_single_provider_when_only_civitai(monkeypatch):
|
||||||
|
# Both archive db and civarchive disabled -> only civitai_api remains,
|
||||||
|
# which takes the single-provider path (registered as default, no fallback).
|
||||||
|
settings = _stub_settings(
|
||||||
|
enable_metadata_archive_db=False,
|
||||||
|
enable_civarchive_api=False,
|
||||||
|
)
|
||||||
|
manager = await _run_initialize(monkeypatch, settings)
|
||||||
|
assert "fallback" not in manager.providers
|
||||||
|
assert manager.default_provider == "civitai_api"
|
||||||
|
|
||||||
|
|||||||
@@ -667,3 +667,310 @@ async def test_log_duplicate_filename_summary_silent_when_no_duplicates(tmp_path
|
|||||||
# No warning should be logged when there are no duplicates
|
# No warning should be logged when there are no duplicates
|
||||||
for record in caplog.records:
|
for record in caplog.records:
|
||||||
assert "Duplicate filename conflict detected" not in record.message
|
assert "Duplicate filename conflict detected" not in record.message
|
||||||
|
|
||||||
|
|
||||||
|
# ── _cache_entries_differ ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"a_tags, b_tags, expect_differ",
|
||||||
|
[
|
||||||
|
(["alpha", "beta"], ["beta", "alpha"], False), # order-insensitive
|
||||||
|
(["alpha"], ["alpha", "beta"], True), # count differs
|
||||||
|
([], ["alpha"], True),
|
||||||
|
(None, [], False), # None ≈ []
|
||||||
|
(["alpha"], None, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_cache_entries_differ_tags(a_tags, b_tags, expect_differ):
|
||||||
|
base = {"file_path": "/m/a.safetensors", "model_name": "A", "size": 1}
|
||||||
|
entry_a = {**base, "tags": a_tags}
|
||||||
|
entry_b = {**base, "tags": b_tags}
|
||||||
|
assert ModelScanner._cache_entries_differ(entry_a, entry_b) == expect_differ
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_entries_differ_identical():
|
||||||
|
entry = {
|
||||||
|
"file_path": "/m/a.safetensors", "model_name": "A", "size": 1,
|
||||||
|
"tags": ["x"], "civitai": {"id": 1}, "notes": "hi",
|
||||||
|
}
|
||||||
|
assert ModelScanner._cache_entries_differ(entry, dict(entry)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_entries_differ_field_changed():
|
||||||
|
a = {"file_path": "/m/a.safetensors", "model_name": "A", "size": 1}
|
||||||
|
b = {**a, "model_name": "B"}
|
||||||
|
assert ModelScanner._cache_entries_differ(a, b) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_entries_differ_extra_key():
|
||||||
|
a = {"file_path": "/m/a.safetensors", "model_name": "A"}
|
||||||
|
b = {**a, "extra_field": "value"}
|
||||||
|
assert ModelScanner._cache_entries_differ(a, b) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── sync_cache_from_metadata ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_cache_entry(**overrides) -> dict:
|
||||||
|
entry = {
|
||||||
|
"file_path": "/m/a.safetensors",
|
||||||
|
"model_name": "TestModel",
|
||||||
|
"file_name": "a",
|
||||||
|
"folder": "",
|
||||||
|
"size": 100,
|
||||||
|
"modified": 10.0,
|
||||||
|
"sha256": "abc123",
|
||||||
|
"base_model": "SD1.5",
|
||||||
|
"preview_url": "",
|
||||||
|
"preview_nsfw_level": 0,
|
||||||
|
"from_civitai": True,
|
||||||
|
"favorite": False,
|
||||||
|
"notes": "old note",
|
||||||
|
"usage_tips": "{}",
|
||||||
|
"metadata_source": None,
|
||||||
|
"exclude": False,
|
||||||
|
"db_checked": False,
|
||||||
|
"last_checked_at": 0.0,
|
||||||
|
"tags": ["alpha"],
|
||||||
|
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
|
||||||
|
"civitai_deleted": False,
|
||||||
|
"skip_metadata_refresh": False,
|
||||||
|
"hf_url": "",
|
||||||
|
"license_flags": 113,
|
||||||
|
"hash_status": "completed",
|
||||||
|
}
|
||||||
|
entry.update(overrides)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_cache_no_change(tmp_path: Path):
|
||||||
|
"""When metadata matches the cache entry, return False and mutate nothing."""
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
entry = _make_cache_entry()
|
||||||
|
scanner._cache = ModelCache(
|
||||||
|
raw_data=[dict(entry)], folders=[], name_display_mode="model_name"
|
||||||
|
)
|
||||||
|
await scanner._cache.resort()
|
||||||
|
scanner._tags_count = {"alpha": 1}
|
||||||
|
scanner._hash_index.add_entry("abc123", "/m/a.safetensors")
|
||||||
|
|
||||||
|
# metadata_dict that would produce the identical cache entry
|
||||||
|
metadata_dict = {
|
||||||
|
"file_path": "/m/a.safetensors",
|
||||||
|
"model_name": "TestModel",
|
||||||
|
"file_name": "a",
|
||||||
|
"folder": "",
|
||||||
|
"size": 100,
|
||||||
|
"modified": 10.0,
|
||||||
|
"sha256": "abc123",
|
||||||
|
"base_model": "SD1.5",
|
||||||
|
"preview_url": "",
|
||||||
|
"preview_nsfw_level": 0,
|
||||||
|
"from_civitai": True,
|
||||||
|
"favorite": False,
|
||||||
|
"notes": "old note",
|
||||||
|
"usage_tips": "{}",
|
||||||
|
"tags": ["alpha"],
|
||||||
|
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
|
||||||
|
"hf_url": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = await scanner.sync_cache_from_metadata(
|
||||||
|
"/m/a.safetensors", metadata_dict
|
||||||
|
)
|
||||||
|
assert changed is False
|
||||||
|
# Verify cache was NOT mutated
|
||||||
|
cached = await scanner.get_cached_data()
|
||||||
|
assert cached.raw_data[0]["notes"] == "old note"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_cache_in_place_update(tmp_path: Path):
|
||||||
|
"""When metadata differs, update the cache entry in-place."""
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
entry = _make_cache_entry(notes="old note", tags=["alpha"], model_name="OldName")
|
||||||
|
scanner._cache = ModelCache(
|
||||||
|
raw_data=[dict(entry)], folders=[], name_display_mode="model_name"
|
||||||
|
)
|
||||||
|
await scanner._cache.resort()
|
||||||
|
scanner._tags_count = {"alpha": 1}
|
||||||
|
scanner._hash_index.add_entry("abc123", "/m/a.safetensors")
|
||||||
|
|
||||||
|
# Capture the exact dict object in raw_data before sync
|
||||||
|
original_entry_ref = scanner._cache.raw_data[0]
|
||||||
|
|
||||||
|
metadata_dict = {
|
||||||
|
"file_path": "/m/a.safetensors",
|
||||||
|
"model_name": "NewName",
|
||||||
|
"file_name": "a",
|
||||||
|
"folder": "",
|
||||||
|
"size": 100,
|
||||||
|
"modified": 10.0,
|
||||||
|
"sha256": "abc123",
|
||||||
|
"base_model": "SD1.5",
|
||||||
|
"preview_url": "",
|
||||||
|
"preview_nsfw_level": 0,
|
||||||
|
"from_civitai": True,
|
||||||
|
"favorite": False,
|
||||||
|
"notes": "new note",
|
||||||
|
"usage_tips": "{}",
|
||||||
|
"tags": ["beta", "gamma"],
|
||||||
|
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
|
||||||
|
"hf_url": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = await scanner.sync_cache_from_metadata(
|
||||||
|
"/m/a.safetensors", metadata_dict
|
||||||
|
)
|
||||||
|
assert changed is True
|
||||||
|
|
||||||
|
cached = await scanner.get_cached_data()
|
||||||
|
updated = cached.raw_data[0]
|
||||||
|
# In-place: the same dict object persisted in raw_data
|
||||||
|
assert updated is original_entry_ref
|
||||||
|
assert updated["notes"] == "new note"
|
||||||
|
assert updated["model_name"] == "NewName"
|
||||||
|
assert sorted(updated["tags"]) == ["beta", "gamma"]
|
||||||
|
# Tag counts updated incrementally
|
||||||
|
assert scanner._tags_count.get("alpha", 0) == 0
|
||||||
|
assert scanner._tags_count.get("beta", 0) == 1
|
||||||
|
assert scanner._tags_count.get("gamma", 0) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_cache_not_in_cache_delegates(tmp_path: Path):
|
||||||
|
"""When the file_path is not in the cache at all, fall back to full update."""
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
scanner._cache = ModelCache(raw_data=[], folders=[], name_display_mode="model_name")
|
||||||
|
await scanner._cache.resort()
|
||||||
|
|
||||||
|
metadata_dict = {
|
||||||
|
"file_path": "/m/b.safetensors",
|
||||||
|
"model_name": "BrandNew",
|
||||||
|
"file_name": "b",
|
||||||
|
"folder": "",
|
||||||
|
"size": 200,
|
||||||
|
"modified": 20.0,
|
||||||
|
"sha256": "def456",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"preview_url": "",
|
||||||
|
"preview_nsfw_level": 0,
|
||||||
|
"from_civitai": True,
|
||||||
|
"favorite": False,
|
||||||
|
"notes": "",
|
||||||
|
"usage_tips": "{}",
|
||||||
|
"tags": [],
|
||||||
|
"civitai": {},
|
||||||
|
"hf_url": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = await scanner.sync_cache_from_metadata(
|
||||||
|
"/m/b.safetensors", metadata_dict
|
||||||
|
)
|
||||||
|
assert changed is True
|
||||||
|
cached = await scanner.get_cached_data()
|
||||||
|
assert len(cached.raw_data) == 1
|
||||||
|
assert cached.raw_data[0]["model_name"] == "BrandNew"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_cache_conditional_resort_skipped(tmp_path: Path, monkeypatch):
|
||||||
|
"""When only non-sort-key fields change, resort() is NOT called."""
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
entry = _make_cache_entry(notes="old note", model_name="SameName")
|
||||||
|
scanner._cache = ModelCache(
|
||||||
|
raw_data=[dict(entry)], folders=[], name_display_mode="model_name"
|
||||||
|
)
|
||||||
|
await scanner._cache.resort()
|
||||||
|
scanner._cache._last_sort = ("name", "asc") # name sort is active
|
||||||
|
scanner._tags_count = {"alpha": 1}
|
||||||
|
scanner._hash_index.add_entry("abc123", "/m/a.safetensors")
|
||||||
|
|
||||||
|
# Track resort calls
|
||||||
|
resort_called = False
|
||||||
|
original_resort = scanner._cache.resort
|
||||||
|
|
||||||
|
async def tracking_resort():
|
||||||
|
nonlocal resort_called
|
||||||
|
resort_called = True
|
||||||
|
await original_resort()
|
||||||
|
|
||||||
|
monkeypatch.setattr(scanner._cache, "resort", tracking_resort)
|
||||||
|
|
||||||
|
metadata_dict = {
|
||||||
|
"file_path": "/m/a.safetensors",
|
||||||
|
"model_name": "SameName", # unchanged — no resort needed
|
||||||
|
"file_name": "a",
|
||||||
|
"folder": "",
|
||||||
|
"size": 100,
|
||||||
|
"modified": 10.0,
|
||||||
|
"sha256": "abc123",
|
||||||
|
"base_model": "SD1.5",
|
||||||
|
"preview_url": "",
|
||||||
|
"preview_nsfw_level": 0,
|
||||||
|
"from_civitai": True,
|
||||||
|
"favorite": False,
|
||||||
|
"notes": "updated note", # changed, but not sort-relevant
|
||||||
|
"usage_tips": "{}",
|
||||||
|
"tags": ["alpha"],
|
||||||
|
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
|
||||||
|
"hf_url": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = await scanner.sync_cache_from_metadata(
|
||||||
|
"/m/a.safetensors", metadata_dict
|
||||||
|
)
|
||||||
|
assert changed is True
|
||||||
|
assert resort_called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_cache_conditional_resort_triggered(tmp_path: Path, monkeypatch):
|
||||||
|
"""When the sort-key field changes, resort() IS called."""
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
entry = _make_cache_entry(model_name="OldName")
|
||||||
|
scanner._cache = ModelCache(
|
||||||
|
raw_data=[dict(entry)], folders=[], name_display_mode="model_name"
|
||||||
|
)
|
||||||
|
await scanner._cache.resort()
|
||||||
|
scanner._cache._last_sort = ("name", "asc")
|
||||||
|
scanner._tags_count = {"alpha": 1}
|
||||||
|
scanner._hash_index.add_entry("abc123", "/m/a.safetensors")
|
||||||
|
|
||||||
|
resort_calls = 0
|
||||||
|
original_resort = scanner._cache.resort
|
||||||
|
|
||||||
|
async def tracking_resort():
|
||||||
|
nonlocal resort_calls
|
||||||
|
resort_calls += 1
|
||||||
|
await original_resort()
|
||||||
|
|
||||||
|
monkeypatch.setattr(scanner._cache, "resort", tracking_resort)
|
||||||
|
|
||||||
|
metadata_dict = {
|
||||||
|
"file_path": "/m/a.safetensors",
|
||||||
|
"model_name": "NewName", # changed — should trigger resort
|
||||||
|
"file_name": "a",
|
||||||
|
"folder": "",
|
||||||
|
"size": 100,
|
||||||
|
"modified": 10.0,
|
||||||
|
"sha256": "abc123",
|
||||||
|
"base_model": "SD1.5",
|
||||||
|
"preview_url": "",
|
||||||
|
"preview_nsfw_level": 0,
|
||||||
|
"from_civitai": True,
|
||||||
|
"favorite": False,
|
||||||
|
"notes": "old note",
|
||||||
|
"usage_tips": "{}",
|
||||||
|
"tags": ["alpha"],
|
||||||
|
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
|
||||||
|
"hf_url": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = await scanner.sync_cache_from_metadata(
|
||||||
|
"/m/a.safetensors", metadata_dict
|
||||||
|
)
|
||||||
|
assert changed is True
|
||||||
|
assert resort_calls == 1
|
||||||
|
|||||||
@@ -225,3 +225,119 @@ def test_incremental_updates_only_touch_changed_rows(tmp_path: Path, monkeypatch
|
|||||||
assert second['metadata_source'] == 'archive_db'
|
assert second['metadata_source'] == 'archive_db'
|
||||||
assert second['civitai_deleted'] is True
|
assert second['civitai_deleted'] is True
|
||||||
assert second['civitai']['creator']['username'] == 'builder_v2'
|
assert second['civitai']['creator']['username'] == 'builder_v2'
|
||||||
|
|
||||||
|
|
||||||
|
# ── update_single_model ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_single_model_insert(tmp_path: Path, monkeypatch):
|
||||||
|
"""Insert a brand-new model row via update_single_model."""
|
||||||
|
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
|
||||||
|
db_path = tmp_path / 'cache.sqlite'
|
||||||
|
store = PersistentModelCache(db_path=str(db_path))
|
||||||
|
|
||||||
|
file_path = (tmp_path / 'x.safetensors').as_posix()
|
||||||
|
new_item = {
|
||||||
|
'file_path': file_path,
|
||||||
|
'file_name': 'x',
|
||||||
|
'model_name': 'Model X',
|
||||||
|
'folder': '',
|
||||||
|
'size': 42,
|
||||||
|
'modified': 1.0,
|
||||||
|
'sha256': 'sha-x',
|
||||||
|
'base_model': 'SDXL',
|
||||||
|
'preview_url': '',
|
||||||
|
'preview_nsfw_level': 0,
|
||||||
|
'from_civitai': True,
|
||||||
|
'favorite': True,
|
||||||
|
'notes': 'test note',
|
||||||
|
'usage_tips': '{}',
|
||||||
|
'metadata_source': None,
|
||||||
|
'exclude': False,
|
||||||
|
'db_checked': False,
|
||||||
|
'last_checked_at': 0.0,
|
||||||
|
'tags': ['test', 'new'],
|
||||||
|
'civitai': None,
|
||||||
|
'civitai_deleted': False,
|
||||||
|
'skip_metadata_refresh': False,
|
||||||
|
'license_flags': DEFAULT_LICENSE_FLAGS,
|
||||||
|
'hash_status': 'completed',
|
||||||
|
'hf_url': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
store.update_single_model('dummy', new_item)
|
||||||
|
|
||||||
|
persisted = store.load_cache('dummy')
|
||||||
|
assert persisted is not None
|
||||||
|
items = {item['file_path']: item for item in persisted.raw_data}
|
||||||
|
assert file_path in items
|
||||||
|
assert items[file_path]['model_name'] == 'Model X'
|
||||||
|
assert items[file_path]['favorite'] is True
|
||||||
|
assert sorted(items[file_path]['tags']) == ['new', 'test']
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_single_model_update_tags(tmp_path: Path, monkeypatch):
|
||||||
|
"""Tags are updated incrementally: old tags removed, new tags added."""
|
||||||
|
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
|
||||||
|
db_path = tmp_path / 'cache.sqlite'
|
||||||
|
store = PersistentModelCache(db_path=str(db_path))
|
||||||
|
|
||||||
|
file_path = (tmp_path / 'y.safetensors').as_posix()
|
||||||
|
base = {
|
||||||
|
'file_path': file_path, 'file_name': 'y', 'model_name': 'Y',
|
||||||
|
'folder': '', 'size': 1, 'modified': 1.0, 'sha256': 'sha-y',
|
||||||
|
'base_model': '', 'preview_url': '', 'preview_nsfw_level': 0,
|
||||||
|
'from_civitai': True, 'favorite': False, 'notes': '', 'usage_tips': '{}',
|
||||||
|
'metadata_source': None, 'exclude': False, 'db_checked': False,
|
||||||
|
'last_checked_at': 0.0, 'civitai': None, 'civitai_deleted': False,
|
||||||
|
'skip_metadata_refresh': False, 'license_flags': DEFAULT_LICENSE_FLAGS,
|
||||||
|
'hash_status': 'completed', 'hf_url': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
# First insert with tags [alpha, beta]
|
||||||
|
store.update_single_model('dummy', {**base, 'tags': ['alpha', 'beta']})
|
||||||
|
|
||||||
|
# Now update: replace with [beta, gamma]
|
||||||
|
old_item = {'file_path': file_path, 'tags': ['alpha', 'beta'], 'sha256': 'sha-y'}
|
||||||
|
new_item = {**base, 'tags': ['beta', 'gamma']}
|
||||||
|
store.update_single_model('dummy', new_item, old_item=old_item)
|
||||||
|
|
||||||
|
persisted = store.load_cache('dummy')
|
||||||
|
assert persisted is not None
|
||||||
|
items = {item['file_path']: item for item in persisted.raw_data}
|
||||||
|
assert sorted(items[file_path]['tags']) == ['beta', 'gamma']
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_single_model_update_hash(tmp_path: Path, monkeypatch):
|
||||||
|
"""When sha256 changes, the hash_index is updated incrementally."""
|
||||||
|
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
|
||||||
|
db_path = tmp_path / 'cache.sqlite'
|
||||||
|
store = PersistentModelCache(db_path=str(db_path))
|
||||||
|
|
||||||
|
file_path = (tmp_path / 'z.safetensors').as_posix()
|
||||||
|
base = {
|
||||||
|
'file_path': file_path, 'file_name': 'z', 'model_name': 'Z',
|
||||||
|
'folder': '', 'size': 1, 'modified': 1.0, 'base_model': '',
|
||||||
|
'preview_url': '', 'preview_nsfw_level': 0, 'from_civitai': True,
|
||||||
|
'favorite': False, 'notes': '', 'usage_tips': '{}',
|
||||||
|
'metadata_source': None, 'exclude': False, 'db_checked': False,
|
||||||
|
'last_checked_at': 0.0, 'tags': [], 'civitai': None,
|
||||||
|
'civitai_deleted': False, 'skip_metadata_refresh': False,
|
||||||
|
'license_flags': DEFAULT_LICENSE_FLAGS, 'hash_status': 'completed', 'hf_url': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
store.update_single_model('dummy', {**base, 'sha256': 'old-hash'})
|
||||||
|
|
||||||
|
old_item = {'file_path': file_path, 'tags': [], 'sha256': 'old-hash'}
|
||||||
|
new_item = {**base, 'sha256': 'new-hash'}
|
||||||
|
store.update_single_model('dummy', new_item, old_item=old_item)
|
||||||
|
|
||||||
|
persisted = store.load_cache('dummy')
|
||||||
|
assert persisted is not None
|
||||||
|
# old hash should be gone from hash_index
|
||||||
|
old_hash_pairs = [p for p in persisted.hash_rows if p[0] == 'old-hash']
|
||||||
|
assert len(old_hash_pairs) == 0
|
||||||
|
# new hash should be present
|
||||||
|
new_hash_pairs = [p for p in persisted.hash_rows if p[0] == 'new-hash']
|
||||||
|
assert len(new_hash_pairs) == 1
|
||||||
|
assert new_hash_pairs[0][1] == file_path
|
||||||
|
|||||||
@@ -0,0 +1,638 @@
|
|||||||
|
<template>
|
||||||
|
<div class="lora-info-widget" :class="{ 'lm-vue-node': isVueMode }" @wheel="onWheel">
|
||||||
|
<template v-if="loraName">
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<div class="lora-info-tabs">
|
||||||
|
<label
|
||||||
|
class="lora-info-tab"
|
||||||
|
:class="{ active: activeTab === 'notes' }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
v-model="activeTab"
|
||||||
|
value="notes"
|
||||||
|
class="lora-info-tab-input"
|
||||||
|
/>
|
||||||
|
<span class="lora-info-tab-label">Notes</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
class="lora-info-tab"
|
||||||
|
:class="{ active: activeTab === 'description' }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
v-model="activeTab"
|
||||||
|
value="description"
|
||||||
|
class="lora-info-tab-input"
|
||||||
|
@change="onDescriptionTabActivated"
|
||||||
|
/>
|
||||||
|
<span class="lora-info-tab-label">Description</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes tab content -->
|
||||||
|
<div v-show="activeTab === 'notes'" class="tab-content notes-tab">
|
||||||
|
<div class="info-field">
|
||||||
|
<label class="info-label">Filename</label>
|
||||||
|
<div class="lora-filename">{{ loraName }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-field notes-field">
|
||||||
|
<label class="info-label">Notes</label>
|
||||||
|
<textarea
|
||||||
|
v-model="notes"
|
||||||
|
class="lora-notes lm-wheel-scrollable"
|
||||||
|
placeholder="Add notes about this LoRA..."
|
||||||
|
:disabled="saving"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="save-btn"
|
||||||
|
:disabled="notes === originalNotes || saving"
|
||||||
|
@click="saveNotes"
|
||||||
|
>
|
||||||
|
{{ saving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description tab content -->
|
||||||
|
<div v-show="activeTab === 'description'" class="tab-content description-tab lm-wheel-scrollable">
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="descriptionLoading" class="description-state">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
<span>Loading description...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="descriptionError" class="description-state error">
|
||||||
|
<span>Failed to load description</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state (loaded but no content) -->
|
||||||
|
<div v-else-if="!hasDescription" class="description-state placeholder">
|
||||||
|
<span>No description available</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description content -->
|
||||||
|
<div v-else class="description-content">
|
||||||
|
<div v-if="versionDescription" class="description-section">
|
||||||
|
<label class="info-label">About this version</label>
|
||||||
|
<div class="description-text" v-html="versionDescription"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="modelDescription" class="description-section">
|
||||||
|
<label class="info-label">Model Description</label>
|
||||||
|
<div class="description-text" v-html="modelDescription"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="placeholder">No LoRA selected</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed, watch } from 'vue'
|
||||||
|
|
||||||
|
interface LoraInfoWidget {
|
||||||
|
serializeValue?: () => Promise<unknown>
|
||||||
|
value?: unknown
|
||||||
|
onSetValue?: (v: unknown) => void
|
||||||
|
callback?: unknown
|
||||||
|
options?: {
|
||||||
|
getValue?: () => LoraInfoWidgetValue
|
||||||
|
setValue?: (v: unknown) => void
|
||||||
|
}
|
||||||
|
node?: { widgets?: Array<{ id?: string }>; widgets_values?: Array<unknown> }
|
||||||
|
id?: string
|
||||||
|
_setLoraInfo?: (data: { name: string; notes: string; filePath: string; activeTab?: string } | null) => void
|
||||||
|
__pendingLoraInfo?: { name: string; notes: string; filePath: string; activeTab?: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoraInfoWidgetValue {
|
||||||
|
name?: string
|
||||||
|
notes?: string
|
||||||
|
filePath?: string
|
||||||
|
activeTab?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
widget: LoraInfoWidget
|
||||||
|
node: { id: number }
|
||||||
|
api: { fetchApi: (url: string, options?: RequestInit) => Promise<Response> }
|
||||||
|
app: { extensionManager: { toast: { add: (opts: Record<string, unknown>) => void } } }
|
||||||
|
isVueMode?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const loraName = ref<string>('')
|
||||||
|
const notes = ref<string>('')
|
||||||
|
const originalNotes = ref<string>('')
|
||||||
|
const filePath = ref<string>('')
|
||||||
|
const saving = ref<boolean>(false)
|
||||||
|
const activeTab = ref<string>('notes')
|
||||||
|
|
||||||
|
// Description tab state
|
||||||
|
const versionDescription = ref<string>('')
|
||||||
|
const modelDescription = ref<string>('')
|
||||||
|
const descriptionLoading = ref<boolean>(false)
|
||||||
|
const descriptionError = ref<boolean>(false)
|
||||||
|
const descriptionLoaded = ref<boolean>(false)
|
||||||
|
|
||||||
|
const hasDescription = computed(() =>
|
||||||
|
!!(versionDescription.value || modelDescription.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset and auto-fetch description state when the LoRA selection changes
|
||||||
|
watch(filePath, (newPath) => {
|
||||||
|
descriptionLoaded.value = false
|
||||||
|
descriptionError.value = false
|
||||||
|
versionDescription.value = ''
|
||||||
|
modelDescription.value = ''
|
||||||
|
if (newPath && activeTab.value === 'description') {
|
||||||
|
fetchDescription()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onDescriptionTabActivated() {
|
||||||
|
if (!descriptionLoaded.value && filePath.value) {
|
||||||
|
fetchDescription()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDescription() {
|
||||||
|
if (descriptionLoading.value || !filePath.value) return
|
||||||
|
|
||||||
|
descriptionLoading.value = true
|
||||||
|
descriptionError.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await props.api.fetchApi(
|
||||||
|
`/lm/loras/metadata?file_path=${encodeURIComponent(filePath.value)}`,
|
||||||
|
{ method: 'GET' }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch metadata: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success && data.metadata) {
|
||||||
|
versionDescription.value = data.metadata.description || ''
|
||||||
|
modelDescription.value = data.metadata.model?.description || ''
|
||||||
|
descriptionLoaded.value = true
|
||||||
|
} else {
|
||||||
|
// Successful response but no metadata — treat as empty, not error
|
||||||
|
descriptionLoaded.value = true
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[LoraInfoWidget] Failed to fetch description:', e)
|
||||||
|
descriptionError.value = true
|
||||||
|
// Don't set descriptionLoaded — allow retry on next tab switch
|
||||||
|
} finally {
|
||||||
|
descriptionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNotes() {
|
||||||
|
if (notes.value === originalNotes.value || saving.value) return
|
||||||
|
if (!filePath.value) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const response = await props.api.fetchApi('/lm/loras/save-metadata', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ file_path: filePath.value, notes: notes.value })
|
||||||
|
})
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
props.app.extensionManager.toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Saved',
|
||||||
|
detail: 'Notes updated successfully',
|
||||||
|
life: 2000
|
||||||
|
})
|
||||||
|
originalNotes.value = notes.value
|
||||||
|
} else {
|
||||||
|
props.app.extensionManager.toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: result.message || result.error || 'Failed to save notes',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[LoraInfoWidget] Failed to save notes:', e)
|
||||||
|
props.app.extensionManager.toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: (e as Error).message || 'Failed to save notes',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(event: WheelEvent) {
|
||||||
|
const target = event.target as HTMLElement | null
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
const comfyApp = (window as unknown as { app?: { canvas?: { processMouseWheel?: (e: WheelEvent) => void } } }).app
|
||||||
|
if (!comfyApp?.canvas?.processMouseWheel) return
|
||||||
|
|
||||||
|
// Always pass pinch-to-zoom to canvas
|
||||||
|
if (event.ctrlKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
comfyApp.canvas.processMouseWheel(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal scroll: pass to canvas
|
||||||
|
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
comfyApp.canvas.processMouseWheel(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the target is inside a scrollable area (notes textarea or description tab)
|
||||||
|
const scrollableEl = target.closest('.lora-notes, .description-tab') as HTMLElement | null
|
||||||
|
if (scrollableEl) {
|
||||||
|
const canScrollY = scrollableEl.scrollHeight > scrollableEl.clientHeight
|
||||||
|
if (canScrollY) {
|
||||||
|
// Let native scroll handle it, but stop propagation to prevent canvas zoom
|
||||||
|
event.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward to canvas for zoom
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
comfyApp.canvas.processMouseWheel(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Build current state snapshot for serialization
|
||||||
|
const buildValue = (): LoraInfoWidgetValue => ({
|
||||||
|
name: loraName.value,
|
||||||
|
notes: notes.value,
|
||||||
|
filePath: filePath.value,
|
||||||
|
activeTab: activeTab.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set value from external source (workflow load, paste, etc.)
|
||||||
|
const applyValue = (v: unknown) => {
|
||||||
|
if (v && typeof v === 'object') {
|
||||||
|
const data = v as LoraInfoWidgetValue
|
||||||
|
// Set activeTab before filePath so the filePath watcher sees the correct tab
|
||||||
|
// and triggers fetchDescription() when restoring description tab
|
||||||
|
if (data.activeTab !== undefined) activeTab.value = data.activeTab
|
||||||
|
if (data.name !== undefined) loraName.value = data.name
|
||||||
|
if (data.notes !== undefined) {
|
||||||
|
notes.value = data.notes
|
||||||
|
originalNotes.value = data.notes
|
||||||
|
}
|
||||||
|
if (data.filePath !== undefined) filePath.value = data.filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComponentWidgetImpl.value getter/setter delegates to options.getValue/options.setValue.
|
||||||
|
// These must be set for workflow JSON persistence (LGraphNode.serialize/configure) to work.
|
||||||
|
props.widget.options.getValue = buildValue
|
||||||
|
props.widget.options.setValue = applyValue
|
||||||
|
|
||||||
|
// Also set serializeValue for prompt/API serialization path (executionUtil.ts)
|
||||||
|
props.widget.serializeValue = async () => buildValue()
|
||||||
|
|
||||||
|
// Handle external value updates (e.g., loading workflow, paste)
|
||||||
|
props.widget.onSetValue = applyValue
|
||||||
|
|
||||||
|
// Restore from saved value. Because configure() may call widget.value = data
|
||||||
|
// before onMounted fires (and before options.setValue is assigned), we check
|
||||||
|
// widgets_values directly in case the value was already pushed.
|
||||||
|
const widgetIndex = props.widget.node?.widgets?.findIndex(
|
||||||
|
(w: { id?: string }) => w.id === props.widget.id
|
||||||
|
)
|
||||||
|
let restored = false
|
||||||
|
if (widgetIndex !== undefined && widgetIndex >= 0) {
|
||||||
|
const savedValue = props.widget.node?.widgets_values?.[widgetIndex]
|
||||||
|
if (savedValue && typeof savedValue === 'object') {
|
||||||
|
applyValue(savedValue)
|
||||||
|
restored = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: if configure() ran after onMounted, widget.value (via options.getValue)
|
||||||
|
// already has the saved data. Only use this path if the widgets_values lookup didn't restore.
|
||||||
|
if (!restored && props.widget.value && typeof props.widget.value === 'object') {
|
||||||
|
applyValue(props.widget.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose setLoraInfo on the widget object for external callers (e.g., lora_info.js).
|
||||||
|
// Accepts null to clear the display (when selection is deselected).
|
||||||
|
props.widget._setLoraInfo = (data: { name: string; notes: string; filePath: string; activeTab?: string } | null) => {
|
||||||
|
if (data) {
|
||||||
|
loraName.value = data.name
|
||||||
|
notes.value = data.notes
|
||||||
|
originalNotes.value = data.notes
|
||||||
|
filePath.value = data.filePath
|
||||||
|
// Preserve existing activeTab unless explicitly provided
|
||||||
|
if (data.activeTab !== undefined) {
|
||||||
|
activeTab.value = data.activeTab
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loraName.value = ''
|
||||||
|
notes.value = ''
|
||||||
|
originalNotes.value = ''
|
||||||
|
filePath.value = ''
|
||||||
|
// Do NOT reset activeTab on deselection — user's tab preference persists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume any data pushed before the Vue component mounted (race condition fix)
|
||||||
|
if (props.widget.__pendingLoraInfo) {
|
||||||
|
props.widget._setLoraInfo(props.widget.__pendingLoraInfo)
|
||||||
|
delete props.widget.__pendingLoraInfo
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lora-info-widget {
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(40, 44, 52, 0.6);
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vue node mode: prevent content from pushing node size via ResizeObserver.
|
||||||
|
contain:layout size tells the browser the element's intrinsic size is
|
||||||
|
determined solely by CSS — not by descendant content. This breaks the
|
||||||
|
feedback loop where content grows → ResizeObserver resizes → content
|
||||||
|
reflows → repeat. Same technique used by tags_widget.js + lm_styles.css. */
|
||||||
|
.lora-info-widget.lm-vue-node {
|
||||||
|
contain: layout size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab bar ── */
|
||||||
|
.lora-info-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #444);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-info-tab {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-info-tab-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-info-tab-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg-color, #fff);
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-info-tab:hover .lora-info-tab-label {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-info-tab.active .lora-info-tab-label {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-info-tab.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 25%;
|
||||||
|
right: 25%;
|
||||||
|
height: 2px;
|
||||||
|
background: rgba(66, 153, 225, 0.8);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab content ── */
|
||||||
|
.tab-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Info fields (shared) ── */
|
||||||
|
.info-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--fg-color, #fff);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-filename {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg-color, #fff);
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
/* Override node-level grab cursor and user-select:none from .lg-node.cursor-grab */
|
||||||
|
cursor: auto;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-field {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-notes {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 60px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
background: var(--comfy-input-bg, #333);
|
||||||
|
color: var(--fg-color, #fff);
|
||||||
|
font-size: 12px;
|
||||||
|
resize: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-notes:focus {
|
||||||
|
border-color: var(--comfy-input-border, #444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-notes:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(66, 153, 225, 0.4);
|
||||||
|
background: rgba(66, 153, 225, 0.15);
|
||||||
|
color: var(--fg-color, #fff);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(66, 153, 225, 0.25);
|
||||||
|
border-color: rgba(66, 153, 225, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: rgba(66, 153, 225, 0.05);
|
||||||
|
border-color: rgba(226, 232, 240, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Description states ── */
|
||||||
|
.description-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 24px 16px;
|
||||||
|
color: var(--fg-color, #fff);
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-state.error {
|
||||||
|
opacity: 0.7;
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Description content ── */
|
||||||
|
.description-content {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-section {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-text {
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--fg-color, #fff);
|
||||||
|
opacity: 0.85;
|
||||||
|
word-break: break-word;
|
||||||
|
/* Override node-level grab cursor and user-select:none from .lg-node.cursor-grab */
|
||||||
|
cursor: auto;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-text :deep(p) {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-text :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-text :deep(a) {
|
||||||
|
color: rgba(66, 153, 225, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-text :deep(ul),
|
||||||
|
.description-text :deep(ol) {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-text :deep(h1),
|
||||||
|
.description-text :deep(h2),
|
||||||
|
.description-text :deep(h3) {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 10px 0 4px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-text :deep(code) {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-text :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Placeholder (shared) ── */
|
||||||
|
.placeholder {
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(226, 232, 240, 0.5);
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Spinner (Font Awesome) ── */
|
||||||
|
.fa-spinner {
|
||||||
|
animation: fa-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fa-spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+179
-17
@@ -5,6 +5,7 @@ import LoraRandomizerWidget from '@/components/LoraRandomizerWidget.vue'
|
|||||||
import LoraCyclerWidget from '@/components/LoraCyclerWidget.vue'
|
import LoraCyclerWidget from '@/components/LoraCyclerWidget.vue'
|
||||||
import JsonDisplayWidget from '@/components/JsonDisplayWidget.vue'
|
import JsonDisplayWidget from '@/components/JsonDisplayWidget.vue'
|
||||||
import AutocompleteTextWidget from '@/components/AutocompleteTextWidget.vue'
|
import AutocompleteTextWidget from '@/components/AutocompleteTextWidget.vue'
|
||||||
|
import LoraInfoWidget from '@/components/LoraInfoWidget.vue'
|
||||||
import { createVueWidgetCleanup } from './vue-widget-cleanup'
|
import { createVueWidgetCleanup } from './vue-widget-cleanup'
|
||||||
import type { LoraPoolConfig, RandomizerConfig, CyclerConfig } from './composables/types'
|
import type { LoraPoolConfig, RandomizerConfig, CyclerConfig } from './composables/types'
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,8 @@ const LORA_CYCLER_WIDGET_MIN_HEIGHT = 408
|
|||||||
const LORA_CYCLER_WIDGET_MAX_HEIGHT = LORA_CYCLER_WIDGET_MIN_HEIGHT
|
const LORA_CYCLER_WIDGET_MAX_HEIGHT = LORA_CYCLER_WIDGET_MIN_HEIGHT
|
||||||
const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300
|
const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300
|
||||||
const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200
|
const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200
|
||||||
|
const LORA_INFO_WIDGET_MIN_WIDTH = 300
|
||||||
|
const LORA_INFO_WIDGET_MIN_HEIGHT = 200
|
||||||
const AUTOCOMPLETE_TEXT_WIDGET_MIN_HEIGHT = 60
|
const AUTOCOMPLETE_TEXT_WIDGET_MIN_HEIGHT = 60
|
||||||
const AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT = 100
|
const AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT = 100
|
||||||
// Per-modelType min size hints for node initial sizing.
|
// Per-modelType min size hints for node initial sizing.
|
||||||
@@ -71,7 +74,7 @@ function forwardMiddleMouseToCanvas(container: HTMLElement) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const vueApps = new Map<number, VueApp>()
|
const vueApps = new Map<number | string, VueApp>()
|
||||||
let autocompleteTextWidgetInstanceId = 0
|
let autocompleteTextWidgetInstanceId = 0
|
||||||
|
|
||||||
export function createAutocompleteTextWidgetInstanceId() {
|
export function createAutocompleteTextWidgetInstanceId() {
|
||||||
@@ -402,7 +405,6 @@ function createJsonDisplayWidget(node) {
|
|||||||
return { widget }
|
return { widget }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store nodeData options per widget type for autocomplete widgets
|
|
||||||
const widgetInputOptions: Map<string, { placeholder?: string }> = new Map()
|
const widgetInputOptions: Map<string, { placeholder?: string }> = new Map()
|
||||||
|
|
||||||
function getSerializableWidgetNames(node: any): string[] {
|
function getSerializableWidgetNames(node: any): string[] {
|
||||||
@@ -642,6 +644,75 @@ if (app.ui?.settings) {
|
|||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
function createLoraInfoWidget(node: any) {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.id = `lora-info-widget-${node.id}`
|
||||||
|
container.style.width = '100%'
|
||||||
|
container.style.height = '100%'
|
||||||
|
container.style.display = 'flex'
|
||||||
|
container.style.flexDirection = 'column'
|
||||||
|
container.style.overflow = 'hidden'
|
||||||
|
|
||||||
|
forwardMiddleMouseToCanvas(container)
|
||||||
|
|
||||||
|
let internalValue: { name?: string; notes?: string; filePath?: string; activeTab?: string } | undefined
|
||||||
|
|
||||||
|
const widget = node.addDOMWidget(
|
||||||
|
'lora_info_display',
|
||||||
|
'LORA_INFO_DISPLAY',
|
||||||
|
container,
|
||||||
|
{
|
||||||
|
getValue() {
|
||||||
|
return internalValue
|
||||||
|
},
|
||||||
|
setValue(v: { name?: string; notes?: string; filePath?: string; activeTab?: string }) {
|
||||||
|
internalValue = v
|
||||||
|
if (typeof widget.onSetValue === 'function') {
|
||||||
|
widget.onSetValue(v)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serialize: true,
|
||||||
|
getMinHeight() {
|
||||||
|
return LORA_INFO_WIDGET_MIN_HEIGHT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const vueApp = createApp(LoraInfoWidget, {
|
||||||
|
widget,
|
||||||
|
node,
|
||||||
|
api,
|
||||||
|
app,
|
||||||
|
isVueMode: typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
vueApp.use(PrimeVue, {
|
||||||
|
unstyled: true,
|
||||||
|
ripple: false
|
||||||
|
})
|
||||||
|
|
||||||
|
vueApp.mount(container)
|
||||||
|
vueApps.set(node.id + 40000, vueApp) // Offset to avoid collision
|
||||||
|
|
||||||
|
widget.computeLayoutSize = () => {
|
||||||
|
const minWidth = LORA_INFO_WIDGET_MIN_WIDTH
|
||||||
|
const minHeight = LORA_INFO_WIDGET_MIN_HEIGHT
|
||||||
|
|
||||||
|
return { minHeight, minWidth }
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.onRemove = () => {
|
||||||
|
const vueApp = vueApps.get(node.id + 40000)
|
||||||
|
if (vueApp) {
|
||||||
|
vueApp.unmount()
|
||||||
|
vueApps.delete(node.id + 40000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { widget }
|
||||||
|
}
|
||||||
|
|
||||||
// Factory function for creating autocomplete text widgets
|
// Factory function for creating autocomplete text widgets
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
function createAutocompleteTextWidgetFactory(
|
function createAutocompleteTextWidgetFactory(
|
||||||
@@ -651,16 +722,30 @@ function createAutocompleteTextWidgetFactory(
|
|||||||
inputOptions: { placeholder?: string } = {}
|
inputOptions: { placeholder?: string } = {}
|
||||||
) {
|
) {
|
||||||
const metadataWidgetName = `__lm_autocomplete_meta_${widgetName}`
|
const metadataWidgetName = `__lm_autocomplete_meta_${widgetName}`
|
||||||
const instanceId = createAutocompleteTextWidgetInstanceId()
|
|
||||||
const container = document.createElement('div')
|
let container: HTMLElement | null = null
|
||||||
|
|
||||||
|
const existingContainers = document.querySelectorAll<HTMLElement>(
|
||||||
|
'[id^="autocomplete-text-widget-"]'
|
||||||
|
)
|
||||||
|
for (const el of existingContainers) {
|
||||||
|
if (el.children.length === 0) {
|
||||||
|
container = el
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
const instanceId = String(createAutocompleteTextWidgetInstanceId())
|
||||||
|
container = document.createElement('div')
|
||||||
container.id = `autocomplete-text-widget-${instanceId}`
|
container.id = `autocomplete-text-widget-${instanceId}`
|
||||||
container.style.width = '100%'
|
container.style.width = '100%'
|
||||||
container.style.height = '100%'
|
container.style.height = '100%'
|
||||||
container.style.display = 'flex'
|
container.style.display = 'flex'
|
||||||
container.style.flexDirection = 'column'
|
container.style.flexDirection = 'column'
|
||||||
container.style.overflow = 'hidden'
|
container.style.overflow = 'hidden'
|
||||||
|
|
||||||
forwardMiddleMouseToCanvas(container)
|
forwardMiddleMouseToCanvas(container)
|
||||||
|
}
|
||||||
|
|
||||||
// Store textarea reference on the container element so cloned widgets can access it
|
// Store textarea reference on the container element so cloned widgets can access it
|
||||||
// This is necessary because when widgets are promoted to subgraph nodes,
|
// This is necessary because when widgets are promoted to subgraph nodes,
|
||||||
@@ -739,15 +824,10 @@ function createAutocompleteTextWidgetFactory(
|
|||||||
})
|
})
|
||||||
|
|
||||||
vueApp.mount(container)
|
vueApp.mount(container)
|
||||||
const appKey = instanceId
|
const appKey = container.id
|
||||||
vueApps.set(appKey, vueApp)
|
vueApps.set(appKey, vueApp)
|
||||||
|
|
||||||
if (maxHeight) {
|
if (maxHeight) {
|
||||||
// Set only minHeight as a true minimum — remove maxHeight so the
|
|
||||||
// textarea can grow when the user resizes it in app mode (where
|
|
||||||
// [&_textarea]:resize-y applies). Graph mode (canvas & Vue render)
|
|
||||||
// is unaffected because LiteGraph's layout system still governs
|
|
||||||
// the widget area size.
|
|
||||||
container.style.minHeight = `${AUTOCOMPLETE_TEXT_WIDGET_MIN_HEIGHT}px`
|
container.style.minHeight = `${AUTOCOMPLETE_TEXT_WIDGET_MIN_HEIGHT}px`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,10 +839,14 @@ function createAutocompleteTextWidgetFactory(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
widget.onRemove = createVueWidgetCleanup(vueApp, () => {
|
const vueCleanup = createVueWidgetCleanup(vueApp, () => {
|
||||||
vueApps.delete(appKey)
|
vueApps.delete(appKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
widget.onRemove = () => {
|
||||||
|
vueCleanup()
|
||||||
|
}
|
||||||
|
|
||||||
// Return minWidth/minHeight hints so ComfyUI's _initialMinSize mechanism
|
// Return minWidth/minHeight hints so ComfyUI's _initialMinSize mechanism
|
||||||
// sets a sensible initial node width (and height for prompt/embeddings).
|
// sets a sensible initial node width (and height for prompt/embeddings).
|
||||||
// loras modelType retains its existing height constraints (getMaxHeight: 100).
|
// loras modelType retains its existing height constraints (getMaxHeight: 100).
|
||||||
@@ -804,7 +888,75 @@ app.registerExtension({
|
|||||||
updateDownstreamLoaders(node)
|
updateDownstreamLoaders(node)
|
||||||
} : null
|
} : null
|
||||||
|
|
||||||
return addLorasWidgetCache(node, 'loras', { isRandomizerNode }, callback)
|
const opts: { isRandomizerNode?: boolean; onSelectionChange?: (selection: any) => void } = {
|
||||||
|
isRandomizerNode,
|
||||||
|
}
|
||||||
|
if (isRandomizerNode) {
|
||||||
|
opts.onSelectionChange = async (selection: any) => {
|
||||||
|
if (!selection?.name || !selection?.active) return
|
||||||
|
|
||||||
|
// Walk outputs to find directly connected Lora Info nodes
|
||||||
|
const infoNodes: any[] = []
|
||||||
|
if (node.outputs) {
|
||||||
|
for (const output of node.outputs) {
|
||||||
|
if (!output?.links?.length) continue
|
||||||
|
for (const linkId of output.links) {
|
||||||
|
const links = node.graph?.links
|
||||||
|
if (!links) continue
|
||||||
|
const link = Array.isArray(links) ? links[linkId] : links.get?.(linkId)
|
||||||
|
if (!link) continue
|
||||||
|
const targetNode = node.graph?.getNodeById?.(link.target_id)
|
||||||
|
if (targetNode?.comfyClass === 'Lora Info (LoraManager)') {
|
||||||
|
infoNodes.push(targetNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (infoNodes.length === 0) return
|
||||||
|
|
||||||
|
// Bump request token to guard against stale async responses
|
||||||
|
for (const infoNode of infoNodes) {
|
||||||
|
infoNode.__loraInfoReqId = (infoNode.__loraInfoReqId || 0) + 1
|
||||||
|
}
|
||||||
|
const reqIdSnapshot = new Map<any, number>()
|
||||||
|
for (const infoNode of infoNodes) {
|
||||||
|
reqIdSnapshot.set(infoNode, infoNode.__loraInfoReqId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch notes via the real ComfyUI api
|
||||||
|
let infoData: any
|
||||||
|
try {
|
||||||
|
const response = await api.fetchApi(
|
||||||
|
`/lm/loras/get-notes?name=${encodeURIComponent(selection.name)}`,
|
||||||
|
{ method: 'GET' }
|
||||||
|
)
|
||||||
|
if (response?.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
infoData = {
|
||||||
|
name: selection.name,
|
||||||
|
notes: data?.notes || '',
|
||||||
|
filePath: data?.file_path || '',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
infoData = { name: selection.name, notes: '[Error loading notes]', filePath: '' }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
infoData = { name: selection.name, notes: '[Error loading notes]', filePath: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const infoNode of infoNodes) {
|
||||||
|
if (infoNode.__loraInfoReqId !== reqIdSnapshot.get(infoNode)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (typeof infoNode._setLoraInfo === 'function') {
|
||||||
|
infoNode._setLoraInfo(infoData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return addLorasWidgetCache(node, 'loras', opts, callback)
|
||||||
},
|
},
|
||||||
// Autocomplete text widget for LoRAs (used by Lora Loader, Lora Stacker, WanVideo Lora Select)
|
// Autocomplete text widget for LoRAs (used by Lora Loader, Lora Stacker, WanVideo Lora Select)
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -823,7 +975,7 @@ app.registerExtension({
|
|||||||
AUTOCOMPLETE_TEXT_PROMPT(node) {
|
AUTOCOMPLETE_TEXT_PROMPT(node) {
|
||||||
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}
|
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}
|
||||||
return createAutocompleteTextWidgetFactory(node, 'text', 'prompt', options)
|
return createAutocompleteTextWidgetFactory(node, 'text', 'prompt', options)
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -868,9 +1020,7 @@ app.registerExtension({
|
|||||||
info.widgets_values = [...(info.widgets_values ?? []), null]
|
info.widgets_values = [...(info.widgets_values ?? []), null]
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = originalConfigure?.apply(this, arguments)
|
return originalConfigure?.apply(this, arguments)
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -903,5 +1053,17 @@ app.registerExtension({
|
|||||||
createJsonDisplayWidget(this)
|
createJsonDisplayWidget(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the Lora Info display widget
|
||||||
|
if (nodeData.name === 'Lora Info (LoraManager)') {
|
||||||
|
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||||
|
|
||||||
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
|
onNodeCreated?.apply(this, [])
|
||||||
|
|
||||||
|
// Create the lora info display widget
|
||||||
|
createLoraInfoWidget(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
/**
|
||||||
|
* Tests for LoraInfoWidget — tab switching, lazy description loading,
|
||||||
|
* state serialization roundtrip, and activeTab persistence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { shallowMount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import LoraInfoWidget from '@/components/LoraInfoWidget.vue'
|
||||||
|
import { setupFetchMock, resetFetchMock } from '../setup'
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function createMockFetchApi(overrides: {
|
||||||
|
response?: unknown
|
||||||
|
ok?: boolean
|
||||||
|
error?: string
|
||||||
|
} = {}) {
|
||||||
|
const { response = { success: true, metadata: {} }, ok = true } = overrides
|
||||||
|
return vi.fn().mockResolvedValue({
|
||||||
|
ok,
|
||||||
|
json: () => Promise.resolve(response),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockToast() {
|
||||||
|
return { add: vi.fn() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockWidget(value?: unknown) {
|
||||||
|
type PendingInfo = { name: string; notes: string; filePath: string; activeTab?: string } | null
|
||||||
|
const widget = {
|
||||||
|
options: {} as { getValue?: () => unknown; setValue?: (v: unknown) => void },
|
||||||
|
serializeValue: (async () => null) as () => Promise<unknown>,
|
||||||
|
value: (value ?? undefined) as unknown,
|
||||||
|
onSetValue: undefined as unknown as ((v: unknown) => void),
|
||||||
|
_setLoraInfo: undefined as unknown as (data: Record<string, unknown> | null) => void,
|
||||||
|
__pendingLoraInfo: undefined as unknown as PendingInfo | undefined,
|
||||||
|
}
|
||||||
|
return widget
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MountOptions {
|
||||||
|
initialValue?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestWidget = ReturnType<typeof createMockWidget>
|
||||||
|
|
||||||
|
function mountWidget(options: MountOptions = {}) {
|
||||||
|
const fetchApi = createMockFetchApi()
|
||||||
|
const widget = createMockWidget(options.initialValue)
|
||||||
|
const node = { id: 1 }
|
||||||
|
const app = { extensionManager: { toast: createMockToast() } }
|
||||||
|
|
||||||
|
const wrapper = shallowMount(LoraInfoWidget, {
|
||||||
|
props: { widget, node, api: { fetchApi }, app },
|
||||||
|
})
|
||||||
|
|
||||||
|
return { wrapper, widget: widget as TestWidget, fetchApi, app }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──
|
||||||
|
|
||||||
|
describe('LoraInfoWidget', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setupFetchMock()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetFetchMock()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('shows placeholder when no LoRA is selected', () => {
|
||||||
|
const { wrapper } = mountWidget()
|
||||||
|
expect(wrapper.text()).toContain('No LoRA selected')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Notes tab by default when LoRA is set', async () => {
|
||||||
|
const { wrapper, widget } = mountWidget()
|
||||||
|
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('test.safetensors')
|
||||||
|
expect(wrapper.find('.notes-tab').isVisible()).toBe(true)
|
||||||
|
expect(wrapper.find('.description-tab').isVisible()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tab switching', () => {
|
||||||
|
it('switches to Description tab and back to Notes', async () => {
|
||||||
|
const { wrapper, widget } = mountWidget()
|
||||||
|
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const tabs = wrapper.findAll('.lora-info-tab')
|
||||||
|
|
||||||
|
// Click Description tab
|
||||||
|
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||||
|
await descriptionTab.setValue('description')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(tabs[1].classes()).toContain('active')
|
||||||
|
expect(wrapper.text()).toContain('No description available')
|
||||||
|
|
||||||
|
// Switch back to Notes
|
||||||
|
const notesTab = wrapper.findAll('.lora-info-tab-input')[0]
|
||||||
|
await notesTab.setValue('notes')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(tabs[0].classes()).toContain('active')
|
||||||
|
expect(wrapper.text()).toContain('test.safetensors')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('description lazy loading', () => {
|
||||||
|
it('fetches metadata when Description tab is activated', async () => {
|
||||||
|
const fetchApi = createMockFetchApi({
|
||||||
|
response: {
|
||||||
|
success: true,
|
||||||
|
metadata: {
|
||||||
|
description: '<p>Version desc</p>',
|
||||||
|
model: { description: '<p>Model desc</p>' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const widget = createMockWidget()
|
||||||
|
const wrapper = shallowMount(LoraInfoWidget, {
|
||||||
|
props: {
|
||||||
|
widget,
|
||||||
|
node: { id: 1 },
|
||||||
|
api: { fetchApi },
|
||||||
|
app: { extensionManager: { toast: createMockToast() } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Switch to Description tab
|
||||||
|
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||||
|
await descriptionTab.setValue('description')
|
||||||
|
await nextTick()
|
||||||
|
await nextTick() // flush async fetch
|
||||||
|
|
||||||
|
expect(fetchApi).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/lm/loras/metadata'),
|
||||||
|
expect.objectContaining({ method: 'GET' })
|
||||||
|
)
|
||||||
|
expect(wrapper.html()).toContain('Version desc')
|
||||||
|
expect(wrapper.html()).toContain('Model desc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading state while fetching', async () => {
|
||||||
|
// Use a never-resolving promise to simulate loading
|
||||||
|
const fetchApi = vi.fn().mockReturnValue(new Promise(() => {}))
|
||||||
|
const widget = createMockWidget()
|
||||||
|
const wrapper = shallowMount(LoraInfoWidget, {
|
||||||
|
props: {
|
||||||
|
widget,
|
||||||
|
node: { id: 1 },
|
||||||
|
api: { fetchApi },
|
||||||
|
app: { extensionManager: { toast: createMockToast() } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||||
|
await descriptionTab.setValue('description')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Loading description')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error state when fetch fails', async () => {
|
||||||
|
const fetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||||
|
const widget = createMockWidget()
|
||||||
|
const wrapper = shallowMount(LoraInfoWidget, {
|
||||||
|
props: {
|
||||||
|
widget,
|
||||||
|
node: { id: 1 },
|
||||||
|
api: { fetchApi },
|
||||||
|
app: { extensionManager: { toast: createMockToast() } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||||
|
await descriptionTab.setValue('description')
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Failed to load description')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when metadata has no descriptions', async () => {
|
||||||
|
const fetchApi = createMockFetchApi({
|
||||||
|
response: {
|
||||||
|
success: true,
|
||||||
|
metadata: {
|
||||||
|
description: '',
|
||||||
|
model: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const widget = createMockWidget()
|
||||||
|
const wrapper = shallowMount(LoraInfoWidget, {
|
||||||
|
props: {
|
||||||
|
widget,
|
||||||
|
node: { id: 1 },
|
||||||
|
api: { fetchApi },
|
||||||
|
app: { extensionManager: { toast: createMockToast() } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||||
|
await descriptionTab.setValue('description')
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('No description available')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caches description and does not re-fetch on second activation', async () => {
|
||||||
|
const fetchApi = createMockFetchApi({
|
||||||
|
response: {
|
||||||
|
success: true,
|
||||||
|
metadata: {
|
||||||
|
description: '<p>Version desc</p>',
|
||||||
|
model: { description: '<p>Model desc</p>' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const widget = createMockWidget()
|
||||||
|
const wrapper = shallowMount(LoraInfoWidget, {
|
||||||
|
props: {
|
||||||
|
widget,
|
||||||
|
node: { id: 1 },
|
||||||
|
api: { fetchApi },
|
||||||
|
app: { extensionManager: { toast: createMockToast() } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// First activation
|
||||||
|
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||||
|
await descriptionTab.setValue('description')
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(fetchApi).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Switch away and back
|
||||||
|
const notesTab = wrapper.findAll('.lora-info-tab-input')[0]
|
||||||
|
await notesTab.setValue('notes')
|
||||||
|
await nextTick()
|
||||||
|
await descriptionTab.setValue('description')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Should NOT have called fetch again
|
||||||
|
expect(fetchApi).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-fetches when LoRA selection changes', async () => {
|
||||||
|
const fetchApi = createMockFetchApi({
|
||||||
|
response: {
|
||||||
|
success: true,
|
||||||
|
metadata: {
|
||||||
|
description: '<p>Version desc</p>',
|
||||||
|
model: { description: '<p>Model desc</p>' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const widget = createMockWidget()
|
||||||
|
const wrapper = shallowMount(LoraInfoWidget, {
|
||||||
|
props: {
|
||||||
|
widget,
|
||||||
|
node: { id: 1 },
|
||||||
|
api: { fetchApi },
|
||||||
|
app: { extensionManager: { toast: createMockToast() } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
widget._setLoraInfo!({ name: 'first.safetensors', notes: '', filePath: '/path/first.safetensors' })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||||
|
await descriptionTab.setValue('description')
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(fetchApi).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Select a different LoRA — resets description state
|
||||||
|
widget._setLoraInfo!({ name: 'second.safetensors', notes: '', filePath: '/path/second.safetensors' })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Should show loading again (not cached)
|
||||||
|
await descriptionTab.setValue('description')
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(fetchApi).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('serialization roundtrip', () => {
|
||||||
|
it('serializeValue includes activeTab', async () => {
|
||||||
|
const { wrapper, widget } = mountWidget()
|
||||||
|
widget._setLoraInfo!({ name: 'test.safetensors', notes: 'my notes', filePath: '/path/test.safetensors' })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Switch to Description tab
|
||||||
|
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||||
|
await descriptionTab.setValue('description')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const serialized = await widget.serializeValue!()
|
||||||
|
expect(serialized).toMatchObject({
|
||||||
|
name: 'test.safetensors',
|
||||||
|
notes: 'my notes',
|
||||||
|
filePath: '/path/test.safetensors',
|
||||||
|
activeTab: 'description',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onSetValue restores activeTab from workflow value', async () => {
|
||||||
|
const { wrapper } = mountWidget({
|
||||||
|
initialValue: {
|
||||||
|
name: 'saved.safetensors',
|
||||||
|
notes: 'saved notes',
|
||||||
|
filePath: '/path/saved.safetensors',
|
||||||
|
activeTab: 'description',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Description tab should be visible (activeTab restored to 'description')
|
||||||
|
expect(wrapper.find('.description-tab').isVisible()).toBe(true)
|
||||||
|
expect(wrapper.text()).toContain('saved.safetensors')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to notes tab when activeTab is missing in saved value', async () => {
|
||||||
|
const { wrapper } = mountWidget({
|
||||||
|
initialValue: {
|
||||||
|
name: 'legacy.safetensors',
|
||||||
|
notes: 'legacy notes',
|
||||||
|
filePath: '/path/legacy.safetensors',
|
||||||
|
// No activeTab — legacy workflow
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('.notes-tab').isVisible()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('_setLoraInfo race condition guard', () => {
|
||||||
|
it('consumes __pendingLoraInfo pushed before mount', async () => {
|
||||||
|
const widget = createMockWidget()
|
||||||
|
widget.__pendingLoraInfo = {
|
||||||
|
name: 'pending.safetensors',
|
||||||
|
notes: 'pending notes',
|
||||||
|
filePath: '/path/pending.safetensors',
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapper = shallowMount(LoraInfoWidget, {
|
||||||
|
props: {
|
||||||
|
widget,
|
||||||
|
node: { id: 1 },
|
||||||
|
api: { fetchApi: createMockFetchApi() },
|
||||||
|
app: { extensionManager: { toast: createMockToast() } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(widget.__pendingLoraInfo).toBeUndefined()
|
||||||
|
expect(wrapper.text()).toContain('pending.safetensors')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves activeTab when _setLoraInfo called with null (deselection)', async () => {
|
||||||
|
const { wrapper, widget } = mountWidget()
|
||||||
|
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Switch to Description tab
|
||||||
|
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||||
|
await descriptionTab.setValue('description')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Deselect — template shows placeholder (no tab bar rendered)
|
||||||
|
widget._setLoraInfo!(null)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Placeholder shown
|
||||||
|
expect(wrapper.text()).toContain('No LoRA selected')
|
||||||
|
|
||||||
|
// Re-select — activeTab should still be 'description'
|
||||||
|
widget._setLoraInfo!({ name: 'second.safetensors', notes: '', filePath: '/path/second.safetensors' })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const tabs = wrapper.findAll('.lora-info-tab')
|
||||||
|
expect(tabs[1].classes()).toContain('active')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -133,6 +133,14 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Vue node mode: prevent content from pushing node size via ResizeObserver.
|
||||||
|
Same technique as .lm-loras-container.lm-vue-node above. */
|
||||||
|
.comfy-tags-container.lm-vue-node {
|
||||||
|
height: 100%;
|
||||||
|
min-height: var(--comfy-widget-min-height, 150px);
|
||||||
|
contain: layout size;
|
||||||
|
}
|
||||||
|
|
||||||
.lm-lora-empty-state {
|
.lm-lora-empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
import {
|
||||||
|
getLinkFromGraph,
|
||||||
|
chainCallback,
|
||||||
|
} from "./utils.js";
|
||||||
|
|
||||||
|
const LORA_INFO_CLASS = "Lora Info (LoraManager)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find Lora Info nodes directly connected to the given node's outputs.
|
||||||
|
* Mirrors the getConnectedTriggerToggleNodes pattern from utils.js.
|
||||||
|
* @param {object} node - The source node to check outputs from
|
||||||
|
* @returns {object[]} Array of connected Lora Info node instances
|
||||||
|
*/
|
||||||
|
export function getConnectedLoraInfoNodes(node) {
|
||||||
|
const connectedNodes = [];
|
||||||
|
|
||||||
|
if (!node?.outputs) {
|
||||||
|
return connectedNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const output of node.outputs) {
|
||||||
|
if (!output?.links?.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const linkId of output.links) {
|
||||||
|
const link = getLinkFromGraph(node.graph, linkId);
|
||||||
|
if (!link) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetNode = node.graph?.getNodeById?.(link.target_id);
|
||||||
|
if (targetNode && targetNode.comfyClass === LORA_INFO_CLASS) {
|
||||||
|
connectedNodes.push(targetNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connectedNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch notes for the selected lora and push them to all directly connected
|
||||||
|
* Lora Info nodes (no recursive chain traversal — only direct connections).
|
||||||
|
* @param {object} node - The source LoRA Loader/Stacker node
|
||||||
|
* @param {object|null} selection - The current lora selection {name, active, entry}
|
||||||
|
*/
|
||||||
|
export async function updateConnectedLoraInfoNodes(node, selection) {
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoNodes = getConnectedLoraInfoNodes(node);
|
||||||
|
|
||||||
|
if (infoNodes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No selection or inactive — clear the display on all connected info nodes
|
||||||
|
if (!selection?.name || !selection?.active) {
|
||||||
|
for (const infoNode of infoNodes) {
|
||||||
|
infoNode.__loraInfoReqId = (infoNode.__loraInfoReqId || 0) + 1;
|
||||||
|
if (typeof infoNode._setLoraInfo === "function") {
|
||||||
|
infoNode._setLoraInfo(null);
|
||||||
|
} else {
|
||||||
|
infoNode.__pendingLoraInfo = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bump request token on each info node to guard against stale async responses
|
||||||
|
for (const infoNode of infoNodes) {
|
||||||
|
infoNode.__loraInfoReqId = (infoNode.__loraInfoReqId || 0) + 1;
|
||||||
|
}
|
||||||
|
const reqIdSnapshot = new Map();
|
||||||
|
for (const infoNode of infoNodes) {
|
||||||
|
reqIdSnapshot.set(infoNode, infoNode.__loraInfoReqId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch notes for the selected lora
|
||||||
|
try {
|
||||||
|
const response = await api.fetchApi(
|
||||||
|
`/lm/loras/get-notes?name=${encodeURIComponent(selection.name)}`,
|
||||||
|
{ method: "GET" }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response?.ok) {
|
||||||
|
throw new Error(`Failed to fetch notes for ${selection.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const infoData = {
|
||||||
|
name: selection.name,
|
||||||
|
notes: data?.notes || "",
|
||||||
|
filePath: data?.file_path || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const infoNode of infoNodes) {
|
||||||
|
// Discard if a newer request has been issued for this node
|
||||||
|
if (infoNode.__loraInfoReqId !== reqIdSnapshot.get(infoNode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof infoNode._setLoraInfo === "function") {
|
||||||
|
infoNode._setLoraInfo(infoData);
|
||||||
|
} else {
|
||||||
|
infoNode.__pendingLoraInfo = infoData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching notes for lora info:", error);
|
||||||
|
|
||||||
|
const errorData = {
|
||||||
|
name: selection.name,
|
||||||
|
notes: "[Error loading notes]",
|
||||||
|
filePath: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const infoNode of infoNodes) {
|
||||||
|
if (infoNode.__loraInfoReqId !== reqIdSnapshot.get(infoNode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof infoNode._setLoraInfo === "function") {
|
||||||
|
infoNode._setLoraInfo(errorData);
|
||||||
|
} else {
|
||||||
|
infoNode.__pendingLoraInfo = errorData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "LoraManager.LoraInfo",
|
||||||
|
|
||||||
|
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||||
|
if (nodeData.name !== LORA_INFO_CLASS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||||
|
// Add wire-only input for receiving connections from LoRA nodes
|
||||||
|
this.addInput("lora_source", "*", { shape: 7 });
|
||||||
|
|
||||||
|
// Forward lora info data to the Vue widget when available.
|
||||||
|
this._setLoraInfo = function (data) {
|
||||||
|
const widget = this.widgets?.find(
|
||||||
|
(w) => w.type === "LORA_INFO_DISPLAY"
|
||||||
|
);
|
||||||
|
if (widget) {
|
||||||
|
if (typeof widget._setLoraInfo === "function") {
|
||||||
|
widget._setLoraInfo(data);
|
||||||
|
} else {
|
||||||
|
widget.__pendingLoraInfo = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the lora_source wire is disconnected, clear the display.
|
||||||
|
const origOnConnectionsChange = nodeType.prototype.onConnectionsChange;
|
||||||
|
nodeType.prototype.onConnectionsChange = function (type, index, connected, link_info) {
|
||||||
|
if (origOnConnectionsChange) {
|
||||||
|
origOnConnectionsChange.apply(this, arguments);
|
||||||
|
}
|
||||||
|
// type 1 = input connection change; disconnected = !connected
|
||||||
|
if (type === 1 && !connected) {
|
||||||
|
const input = this.inputs?.[index];
|
||||||
|
if (input?.name === "lora_source") {
|
||||||
|
// Check if any lora_source input still has a connection
|
||||||
|
const hasLoraSourceConnection = this.inputs?.some(
|
||||||
|
(inp) => inp.name === "lora_source" && inp.link != null
|
||||||
|
);
|
||||||
|
if (!hasLoraSourceConnection) {
|
||||||
|
this._setLoraInfo?.(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { addLorasWidget } from "./loras_widget.js";
|
import { addLorasWidget } from "./loras_widget.js";
|
||||||
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
|
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
|
||||||
import { applySelectionHighlight } from "./trigger_word_highlight.js";
|
import { applySelectionHighlight } from "./trigger_word_highlight.js";
|
||||||
|
import { updateConnectedLoraInfoNodes } from "./lora_info.js";
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "LoraManager.LoraLoader",
|
name: "LoraManager.LoraLoader",
|
||||||
@@ -185,8 +186,10 @@ app.registerExtension({
|
|||||||
this,
|
this,
|
||||||
"loras",
|
"loras",
|
||||||
{
|
{
|
||||||
onSelectionChange: (selection) =>
|
onSelectionChange: (selection) => {
|
||||||
applySelectionHighlight(this, selection),
|
applySelectionHighlight(this, selection);
|
||||||
|
updateConnectedLoraInfoNodes(this, selection);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
(value) => {
|
(value) => {
|
||||||
// Prevent recursive calls
|
// Prevent recursive calls
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { addLorasWidget } from "./loras_widget.js";
|
import { addLorasWidget } from "./loras_widget.js";
|
||||||
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
|
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
|
||||||
import { applySelectionHighlight } from "./trigger_word_highlight.js";
|
import { applySelectionHighlight } from "./trigger_word_highlight.js";
|
||||||
|
import { updateConnectedLoraInfoNodes } from "./lora_info.js";
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "LoraManager.LoraStacker",
|
name: "LoraManager.LoraStacker",
|
||||||
@@ -64,8 +65,10 @@ app.registerExtension({
|
|||||||
this,
|
this,
|
||||||
"loras",
|
"loras",
|
||||||
{
|
{
|
||||||
onSelectionChange: (selection) =>
|
onSelectionChange: (selection) => {
|
||||||
applySelectionHighlight(this, selection),
|
applySelectionHighlight(this, selection);
|
||||||
|
updateConnectedLoraInfoNodes(this, selection);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
(value) => {
|
(value) => {
|
||||||
// Prevent recursive calls
|
// Prevent recursive calls
|
||||||
|
|||||||
+42
-11
@@ -14,12 +14,31 @@ import { getStrengthStepPreference } from "./settings.js";
|
|||||||
export function addLorasWidget(node, name, opts, callback) {
|
export function addLorasWidget(node, name, opts, callback) {
|
||||||
ensureLmStyles();
|
ensureLmStyles();
|
||||||
|
|
||||||
// Create container for loras
|
// Create container for loras — search for an empty container already
|
||||||
const container = document.createElement("div");
|
// in the DOM first. During undo/redo in ComfyUI Vue render mode,
|
||||||
container.className = "lm-loras-container";
|
// WidgetDOM.vue reuses its component without re-calling
|
||||||
|
// mountWidgetElement(), so we must reuse the existing DOM element
|
||||||
|
// instead of creating an orphaned replacement.
|
||||||
|
let container = null;
|
||||||
|
let reuseExisting = false;
|
||||||
|
const existingContainers = document.querySelectorAll('.lm-loras-container');
|
||||||
|
for (const el of existingContainers) {
|
||||||
|
if (el.children.length === 0) {
|
||||||
|
container = el;
|
||||||
|
reuseExisting = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement("div");
|
||||||
|
container.className = "lm-loras-container";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reuseExisting) {
|
||||||
forwardMiddleMouseToCanvas(container);
|
forwardMiddleMouseToCanvas(container);
|
||||||
forwardWheelToCanvas(container);
|
forwardWheelToCanvas(container);
|
||||||
|
}
|
||||||
|
|
||||||
// Set initial height using CSS variables approach
|
// Set initial height using CSS variables approach
|
||||||
const defaultHeight = 200;
|
const defaultHeight = 200;
|
||||||
@@ -29,10 +48,8 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
// scrolls when content exceeds the allocated space.
|
// scrolls when content exceeds the allocated space.
|
||||||
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
|
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
|
||||||
|
|
||||||
if (typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode) {
|
if (!reuseExisting && typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode) {
|
||||||
container.classList.add('lm-vue-node');
|
container.classList.add('lm-vue-node');
|
||||||
// Window capture-phase hook: scroll the widget instead of zooming the canvas
|
|
||||||
// when the wheel is over a scrollable loras list.
|
|
||||||
enableListWheelScroll(container);
|
enableListWheelScroll(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,8 +297,8 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
selectLora(name);
|
selectLora(name === selectedLora ? null : name);
|
||||||
container.focus(); // Focus container for keyboard events
|
container.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Conditionally create drag handle OR lock button
|
// Conditionally create drag handle OR lock button
|
||||||
@@ -694,7 +711,11 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
// Create widget with new DOM Widget API
|
// Create widget with new DOM Widget API
|
||||||
const widget = node.addDOMWidget(name, "custom", container, {
|
const widget = node.addDOMWidget(name, "custom", container, {
|
||||||
getValue: function() {
|
getValue: function() {
|
||||||
return widgetValue;
|
return widgetValue.map(lora => {
|
||||||
|
const entry = { ...lora };
|
||||||
|
entry.selected = lora.name === selectedLora;
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
setValue: function(v) {
|
setValue: function(v) {
|
||||||
// Remove duplicates by keeping the last occurrence of each lora name
|
// Remove duplicates by keeping the last occurrence of each lora name
|
||||||
@@ -721,6 +742,15 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
widgetValue = updatedValue;
|
widgetValue = updatedValue;
|
||||||
|
|
||||||
|
// Restore selection state when loading a saved workflow
|
||||||
|
if (!selectedLora) {
|
||||||
|
const selectedEntry = updatedValue.find(lora => lora.selected);
|
||||||
|
if (selectedEntry) {
|
||||||
|
selectedLora = selectedEntry.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderLoras(widgetValue, widget);
|
renderLoras(widgetValue, widget);
|
||||||
},
|
},
|
||||||
hideOnZoom: true,
|
hideOnZoom: true,
|
||||||
@@ -732,9 +762,10 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
widget.callback = callback;
|
widget.callback = callback;
|
||||||
|
|
||||||
widget.onRemove = () => {
|
widget.onRemove = () => {
|
||||||
container.remove();
|
while (container.firstChild) {
|
||||||
|
container.removeChild(container.firstChild);
|
||||||
|
}
|
||||||
previewTooltip.cleanup();
|
previewTooltip.cleanup();
|
||||||
// Remove keyboard event listener
|
|
||||||
container.removeEventListener('keydown', handleKeyboardNavigation);
|
container.removeEventListener('keydown', handleKeyboardNavigation);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -438,7 +438,9 @@ export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
|
|||||||
if (firstEntry) {
|
if (firstEntry) {
|
||||||
const rect = firstEntry.getBoundingClientRect();
|
const rect = firstEntry.getBoundingClientRect();
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
dropIndicator.style.top = `${(rect.top - containerRect.top - 2) / scale}px`;
|
// Convert GBCR visual offset to container-local space (rect/containerRect are post-scale,
|
||||||
|
// scrollTop is pre-scale), so only the visual-diff portion is divided by scale
|
||||||
|
dropIndicator.style.top = `${(rect.top - containerRect.top) / scale + container.scrollTop - 2}px`;
|
||||||
dropIndicator.style.opacity = '1';
|
dropIndicator.style.opacity = '1';
|
||||||
}
|
}
|
||||||
} else if (targetIndex < entries.length) {
|
} else if (targetIndex < entries.length) {
|
||||||
@@ -447,7 +449,7 @@ export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
|
|||||||
if (targetEntry) {
|
if (targetEntry) {
|
||||||
const rect = targetEntry.getBoundingClientRect();
|
const rect = targetEntry.getBoundingClientRect();
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
dropIndicator.style.top = `${(rect.top - containerRect.top - 2) / scale}px`;
|
dropIndicator.style.top = `${(rect.top - containerRect.top) / scale + container.scrollTop - 2}px`;
|
||||||
dropIndicator.style.opacity = '1';
|
dropIndicator.style.opacity = '1';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -456,7 +458,7 @@ export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
|
|||||||
if (lastEntry) {
|
if (lastEntry) {
|
||||||
const rect = lastEntry.getBoundingClientRect();
|
const rect = lastEntry.getBoundingClientRect();
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
dropIndicator.style.top = `${(rect.bottom - containerRect.top + 2) / scale}px`;
|
dropIndicator.style.top = `${(rect.bottom - containerRect.top) / scale + container.scrollTop + 2}px`;
|
||||||
dropIndicator.style.opacity = '1';
|
dropIndicator.style.opacity = '1';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import { forwardMiddleMouseToCanvas, forwardWheelToCanvas } from "./utils.js";
|
import { forwardMiddleMouseToCanvas, forwardWheelToCanvas } from "./utils.js";
|
||||||
import { copyToClipboard } from "./loras_widget_utils.js";
|
import { copyToClipboard } from "./loras_widget_utils.js";
|
||||||
|
import { ensureLmStyles } from "./lm_styles_loader.js";
|
||||||
|
|
||||||
const MIN_HEIGHT = 150;
|
const MIN_HEIGHT = 150;
|
||||||
const GROUP_EDITOR_ID = "lm-trigger-group-editor";
|
const GROUP_EDITOR_ID = "lm-trigger-group-editor";
|
||||||
@@ -696,6 +697,16 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
|||||||
outline: "none",
|
outline: "none",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set a fixed minimum height so the node has a reasonable starting size.
|
||||||
|
// Adding or removing tags does NOT change the node size — the container
|
||||||
|
// scrolls when content exceeds the allocated space.
|
||||||
|
ensureLmStyles();
|
||||||
|
container.style.setProperty("--comfy-widget-min-height", `${MIN_HEIGHT}px`);
|
||||||
|
|
||||||
|
if (typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode) {
|
||||||
|
container.classList.add("lm-vue-node");
|
||||||
|
}
|
||||||
|
|
||||||
const initialTagsData = opts?.defaultVal || [];
|
const initialTagsData = opts?.defaultVal || [];
|
||||||
|
|
||||||
function renderSimpleTag(tagData, index, widget, showStrengthInfo) {
|
function renderSimpleTag(tagData, index, widget, showStrengthInfo) {
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ export function mergeLoras(lorasText, lorasArr) {
|
|||||||
active: lora.active !== undefined ? lora.active : true,
|
active: lora.active !== undefined ? lora.active : true,
|
||||||
expanded: lora.expanded !== undefined ? lora.expanded : false,
|
expanded: lora.expanded !== undefined ? lora.expanded : false,
|
||||||
clipStrength: lora.clipStrength !== undefined ? lora.clipStrength : parsedLoras[lora.name].clipStrength,
|
clipStrength: lora.clipStrength !== undefined ? lora.clipStrength : parsedLoras[lora.name].clipStrength,
|
||||||
|
selected: !!lora.selected,
|
||||||
});
|
});
|
||||||
usedNames.add(lora.name);
|
usedNames.add(lora.name);
|
||||||
}
|
}
|
||||||
@@ -451,6 +452,7 @@ export function mergeLoras(lorasText, lorasArr) {
|
|||||||
strength: parsedLoras[name].strength,
|
strength: parsedLoras[name].strength,
|
||||||
active: true,
|
active: true,
|
||||||
clipStrength: parsedLoras[name].clipStrength,
|
clipStrength: parsedLoras[name].clipStrength,
|
||||||
|
selected: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
import { addLorasWidget } from "./loras_widget.js";
|
import { addLorasWidget } from "./loras_widget.js";
|
||||||
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
|
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
|
||||||
|
import { updateConnectedLoraInfoNodes } from "./lora_info.js";
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "LoraManager.WanVideoLoraSelect",
|
name: "LoraManager.WanVideoLoraSelect",
|
||||||
@@ -63,7 +64,11 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = addLorasWidget(this, "loras", {}, (value) => {
|
const result = addLorasWidget(this, "loras", {
|
||||||
|
onSelectionChange: (selection) => {
|
||||||
|
updateConnectedLoraInfoNodes(this, selection);
|
||||||
|
},
|
||||||
|
}, (value) => {
|
||||||
// Prevent recursive calls
|
// Prevent recursive calls
|
||||||
if (isUpdating) return;
|
if (isUpdating) return;
|
||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import { api } from "../../scripts/api.js";
|
import { api } from "../../scripts/api.js";
|
||||||
import { getAllGraphNodes, getNodeReference, getNodeFromGraph } from "./utils.js";
|
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, chainCallback, getLinkFromGraph } from "./utils.js";
|
||||||
import { ensureLmStyles } from "./lm_styles_loader.js";
|
import { ensureLmStyles } from "./lm_styles_loader.js";
|
||||||
|
|
||||||
|
const DEBOUNCE_DELAY = 500;
|
||||||
|
|
||||||
const LORA_NODE_CLASSES = new Set([
|
const LORA_NODE_CLASSES = new Set([
|
||||||
"Lora Loader (LoraManager)",
|
"Lora Loader (LoraManager)",
|
||||||
"Lora Stacker (LoraManager)",
|
"Lora Stacker (LoraManager)",
|
||||||
@@ -74,27 +76,160 @@ function fadeWidgetTextColor(widget, fromColor, toColor, duration) {
|
|||||||
return () => { if (rafId) cancelAnimationFrame(rafId); };
|
return () => { if (rafId) cancelAnimationFrame(rafId); };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Primitive node helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set of node type names that represent Primitive value nodes.
|
||||||
|
* Includes both the dynamic PrimitiveNode (created by double-clicking
|
||||||
|
* a widget input) and the static typed primitives from the node library.
|
||||||
|
*/
|
||||||
|
const PRIMITIVE_NODE_TYPES = new Set([
|
||||||
|
"PrimitiveNode", // dynamic (double-click a widget input)
|
||||||
|
"PrimitiveInt",
|
||||||
|
"PrimitiveFloat",
|
||||||
|
"PrimitiveString",
|
||||||
|
"PrimitiveBoolean",
|
||||||
|
"PrimitiveStringMultiline",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true when `node` is any flavour of Primitive node.
|
||||||
|
* @param {Object} node - LiteGraph node instance
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isPrimitiveNodeType(node) {
|
||||||
|
return PRIMITIVE_NODE_TYPES.has(node?.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the 0-based input slot index whose widget name matches `widgetName`.
|
||||||
|
* Returns -1 when no matching input is found.
|
||||||
|
*
|
||||||
|
* Matching strategy (in order):
|
||||||
|
* 1. `input.widget?.name === widgetName` — direct widget ref (preferred)
|
||||||
|
* 2. `input.name === widgetName` — fallback by slot name
|
||||||
|
*
|
||||||
|
* @param {Object} node - LiteGraph node instance
|
||||||
|
* @param {string} widgetName
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function findInputSlotForWidget(node, widgetName) {
|
||||||
|
if (!node || !Array.isArray(node.inputs)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return node.inputs.findIndex(
|
||||||
|
(inp) => inp?.widget?.name === widgetName || inp?.name === widgetName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the input slot that backs `widgetName` on `node` is connected to a
|
||||||
|
* Primitive node, return that Primitive node. Otherwise return null.
|
||||||
|
*
|
||||||
|
* This is the key bridge for the "send gen params → Primitive" flow:
|
||||||
|
* when a KSampler widget (e.g. "steps") has an incoming wire from a
|
||||||
|
* Primitive node, we want to update the Primitive's value instead of the
|
||||||
|
* KSampler widget, because ComfyUI's execution engine reads from the
|
||||||
|
* connected input, not the widget.
|
||||||
|
*
|
||||||
|
* @param {Object} node - the target node (e.g. KSampler)
|
||||||
|
* @param {string} widgetName - e.g. "steps", "cfg", "seed"
|
||||||
|
* @returns {Object|null} - the connected Primitive node, or null
|
||||||
|
*/
|
||||||
|
function tryResolvePrimitiveConnection(node, widgetName) {
|
||||||
|
const slotIndex = findInputSlotForWidget(node, widgetName);
|
||||||
|
if (slotIndex === -1) return null;
|
||||||
|
|
||||||
|
const input = node.inputs[slotIndex];
|
||||||
|
if (input?.link == null) return null;
|
||||||
|
|
||||||
|
const link = getLinkFromGraph(node.graph, input.link);
|
||||||
|
if (!link) return null;
|
||||||
|
|
||||||
|
const originNode = node.graph?.getNodeById?.(link.origin_id);
|
||||||
|
if (!originNode) return null;
|
||||||
|
|
||||||
|
return isPrimitiveNodeType(originNode) ? originNode : null;
|
||||||
|
}
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "LoraManager.WorkflowRegistry",
|
name: "LoraManager.WorkflowRegistry",
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
ensureLmStyles();
|
ensureLmStyles();
|
||||||
|
this._log("extension initialized, clientId=%s", api.clientId ?? api.initialClientId ?? "(pending)");
|
||||||
|
|
||||||
api.addEventListener("lora_registry_refresh", () => {
|
api.addEventListener("lora_registry_refresh", () => {
|
||||||
this.refreshRegistry();
|
this.refreshRegistry(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
api.addEventListener("lm_widget_update", (event) => {
|
api.addEventListener("lm_widget_update", (event) => {
|
||||||
this.applyWidgetUpdate(event?.detail ?? {});
|
this.applyWidgetUpdate(event?.detail ?? {});
|
||||||
});
|
});
|
||||||
|
|
||||||
// React to marker changes from the Node Marker extension
|
|
||||||
window.addEventListener("lm_marker_changed", () => {
|
window.addEventListener("lm_marker_changed", () => {
|
||||||
this.refreshRegistry();
|
this.refreshRegistry();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._hookGraphChanges();
|
||||||
},
|
},
|
||||||
|
|
||||||
async refreshRegistry() {
|
async afterConfigureGraph(_missingNodeTypes, _app) {
|
||||||
|
this._log("afterConfigureGraph: workflow loaded (%s missing types)", _missingNodeTypes?.length ?? 0);
|
||||||
|
await this.refreshRegistry();
|
||||||
|
},
|
||||||
|
|
||||||
|
_hookGraphChanges() {
|
||||||
|
const graph = app.graph;
|
||||||
|
if (!graph) {
|
||||||
|
this._log("app.graph not available, skipping proactive hooks");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hooksInstalled = 0;
|
||||||
|
|
||||||
|
const scheduleRefresh = (source) => {
|
||||||
|
if (this._debounceTimer != null) {
|
||||||
|
clearTimeout(this._debounceTimer);
|
||||||
|
}
|
||||||
|
this._debounceTimer = setTimeout(() => {
|
||||||
|
this._debounceTimer = null;
|
||||||
|
this.refreshRegistry();
|
||||||
|
}, DEBOUNCE_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
chainCallback(graph, "onNodeAdded", () => scheduleRefresh("onNodeAdded"));
|
||||||
|
chainCallback(graph, "onNodeRemoved", () => scheduleRefresh("onNodeRemoved"));
|
||||||
|
hooksInstalled += 2;
|
||||||
|
} catch (e) {
|
||||||
|
this._log("failed to chain LiteGraph hooks: %s", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof api.addEventListener === "function") {
|
||||||
|
try {
|
||||||
|
api.addEventListener("graphChanged", () => scheduleRefresh("graphChanged"));
|
||||||
|
hooksInstalled += 1;
|
||||||
|
} catch (_e) {
|
||||||
|
// graphChanged may not be available on older ComfyUI versions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._log("%s proactive hooks installed on graph", hooksInstalled);
|
||||||
|
},
|
||||||
|
|
||||||
|
_log(format, ...args) {
|
||||||
|
const ts = new Date().toISOString().slice(11, 23);
|
||||||
|
let msg = format;
|
||||||
|
for (const arg of args) {
|
||||||
|
msg = msg.replace(/%s/g, String(arg));
|
||||||
|
}
|
||||||
|
console.debug(`[LM:Registry ${ts}] ${msg}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshRegistry(force = false) {
|
||||||
try {
|
try {
|
||||||
const workflowNodes = [];
|
const workflowNodes = [];
|
||||||
const nodeEntries = getAllGraphNodes(app.graph);
|
const nodeEntries = getAllGraphNodes(app.graph);
|
||||||
@@ -115,7 +250,6 @@ app.registerExtension({
|
|||||||
const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass);
|
const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass);
|
||||||
const markerRole = node.properties?.lm_marker_role ?? null;
|
const markerRole = node.properties?.lm_marker_role ?? null;
|
||||||
|
|
||||||
// Skip nodes with no relevant capability UNLESS they are marked
|
|
||||||
if (!supportsLora && !hasTargetWidget && !hasTextWidget && !markerRole) {
|
if (!supportsLora && !hasTargetWidget && !hasTextWidget && !markerRole) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -146,6 +280,19 @@ app.registerExtension({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clientId = api.clientId ?? api.initialClientId ?? "";
|
||||||
|
|
||||||
|
// Content-based dedup: skip POST if identical to last sent payload,
|
||||||
|
// unless forced (e.g. responding to a lora_registry_refresh WS message
|
||||||
|
// where the backend explicitly requests a re-registration).
|
||||||
|
const fingerprint = JSON.stringify(
|
||||||
|
workflowNodes.map(n => `${n.graph_id}:${n.node_id}|${n.marker_role ?? ""}|${n.mode ?? 0}`).sort()
|
||||||
|
);
|
||||||
|
if (!force && fingerprint === this._lastFingerprint) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._lastFingerprint = fingerprint;
|
||||||
|
|
||||||
const response = await fetch("/api/lm/register-nodes", {
|
const response = await fetch("/api/lm/register-nodes", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -153,7 +300,7 @@ app.registerExtension({
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
nodes: workflowNodes,
|
nodes: workflowNodes,
|
||||||
client_id: api.clientId ?? api.initialClientId ?? "",
|
client_id: clientId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,6 +387,60 @@ app.registerExtension({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Redirect to connected Primitive node when present ----
|
||||||
|
// When a widget input (e.g. "steps", "cfg", "seed" on KSampler)
|
||||||
|
// is wired to a Primitive node, the Primitive's value overrides
|
||||||
|
// the widget value during execution. Update the Primitive
|
||||||
|
// directly so the change actually takes effect.
|
||||||
|
if (widgetName) {
|
||||||
|
const primitiveNode = tryResolvePrimitiveConnection(node, widgetName);
|
||||||
|
if (primitiveNode) {
|
||||||
|
const primWidget = primitiveNode.widgets?.[0];
|
||||||
|
if (primWidget) {
|
||||||
|
let primNewValue = value;
|
||||||
|
if (mode === "append") {
|
||||||
|
const sep =
|
||||||
|
primWidget.value && primWidget.value.length > 0
|
||||||
|
? " "
|
||||||
|
: "";
|
||||||
|
primNewValue = primWidget.value + sep + value;
|
||||||
|
}
|
||||||
|
primWidget.value = primNewValue;
|
||||||
|
if (
|
||||||
|
Array.isArray(primitiveNode.widgets_values) &&
|
||||||
|
primitiveNode.widgets_values.length > 0
|
||||||
|
) {
|
||||||
|
primitiveNode.widgets_values[0] = primNewValue;
|
||||||
|
}
|
||||||
|
if (typeof primWidget.callback === "function") {
|
||||||
|
try {
|
||||||
|
primWidget.callback(primNewValue);
|
||||||
|
} catch (callbackError) {
|
||||||
|
console.error(
|
||||||
|
"LoRA Manager: primitive widget callback failed",
|
||||||
|
callbackError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof primitiveNode.setDirtyCanvas === "function") {
|
||||||
|
primitiveNode.setDirtyCanvas(true);
|
||||||
|
}
|
||||||
|
if (typeof app.graph?.setDirtyCanvas === "function") {
|
||||||
|
app.graph.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
this.flashWidget(primitiveNode, primWidget);
|
||||||
|
console.debug(
|
||||||
|
"LoRA Manager: redirected widget update to Primitive node %s (id=%d) ← %s = %o",
|
||||||
|
primitiveNode.type,
|
||||||
|
primitiveNode.id,
|
||||||
|
widgetName,
|
||||||
|
primNewValue
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Update widget value ----
|
// ---- Update widget value ----
|
||||||
const widgetIndex = node.widgets.indexOf(targetWidget);
|
const widgetIndex = node.widgets.indexOf(targetWidget);
|
||||||
let newValue = value;
|
let newValue = value;
|
||||||
|
|||||||
Reference in New Issue
Block a user