Compare commits

...

6 Commits

Author SHA1 Message Date
Will Miao
519bafebc8 fix(i18n): add missing embedding translation keys, sync locales, clean up dead replaceMode branch 2026-06-11 23:03:14 +08:00
Will Miao
d87863b423 feat(embedding): send embedding to workflow + fix copy button format
- Fix copy button on embedding cards to copy 'embedding:folder/name' format
- Add send-embedding-to-workflow for Prompt (LoraManager), Text (LoraManager),
  and CLIPTextEncode nodes, appending embedding code to text content
- Extend workflow registry to register text-capable nodes by comfyClass
  (not generic widget name 'text') to avoid false matches
- Add mode parameter to update_node_widget API/event for append support
- Fix single/bulk context menus: single shows plain 'Send to Workflow',
  bulk collapses submenu into direct action for embeddings (append-only)
2026-06-11 22:41:42 +08:00
Will Miao
84e9fe2dfb fix(import): defer git import to module-level to prevent startup crash when git executable missing (#971) 2026-06-11 21:47:55 +08:00
Will Miao
46cbcf94c8 fix(recipe): reimport data loss, local file support, and scroll bugs
- Add local file reimport support via _do_reimport_from_local
- Validate source_path BEFORE deleting old recipe (prevent data loss)
- Move delete_recipe after save_recipe (safe ordering)
- Preserve folder location, NSFW level, and carry over user edits
- Remove old timestamp preservation (use current time)
- Add scrollTop reset in resetAndReloadWithVirtualScroll
- Only reload on successful bulk reimport (avoid empty grid)
- Disable preserveScroll for both single and bulk reimport
2026-06-11 21:31:30 +08:00
Will Miao
05f3018495 refactor(stats): move lora_manager_stats.json from loras root to settings_dir/stats/
- Change _get_stats_file_path() to use get_settings_dir()/stats/ instead of
  first loras root directory
- Add _migrate_from_old_location() to copy existing stats from loras root
  to new location on first access, then clean up old file
- Add 'stats' to update protection skip lists (clean, extract, tracking)
  to prevent data loss during ZIP/git upgrades in portable mode
- Add usage_stats entry to backup targets and restore resolver so stats
  are included in automatic snapshots
2026-06-11 18:03:29 +08:00
Will Miao
f565cc35ca feat(stats): track embedding usage from prompt text — Plan A + hybrid approach docs 2026-06-11 17:12:34 +08:00
30 changed files with 820 additions and 104 deletions

View File

@@ -0,0 +1,181 @@
# Embeddings Usage Tracking — Hybrid Approach (Plan C)
> **Status**: Reference document for future implementation
> **Current implementation**: Plan A (prompt text parsing only, see `usage_stats.py:_process_embeddings`)
> **Next step**: Add Plan B as a supplement when edge-case coverage is needed
## Problem
Embeddings in ComfyUI are not loaded through dedicated ComfyUI nodes like LoRAs or
Checkpoints. They are resolved during CLIP tokenization when the prompt text contains
`embedding:<name>` syntax (see `comfy/sd1_clip.py:SDTokenizer.tokenize_with_weights`).
This means the existing metadata_collector hook (which intercepts node execution via
`_map_node_over_list`) cannot capture embeddings the same way it captures LoRAs and
checkpoints — there is no "EmbeddingLoader" node to intercept.
## Solution Architecture
The hybrid approach combines **two complementary mechanisms** to capture embedding
usage from all possible paths.
```
┌─────────────────────────────────────────────────────────┐
│ Plan A (已实现) │
│ │
│ MetadataRegistry.prompt_metadata["prompts"] │
│ │ │
│ ▼ │
│ _process_embeddings() │
│ │ │
│ ├─ Iterate all prompt node texts │
│ ├─ regex extract "embedding:<name>" │
│ ├─ resolve name → sha256 via EmbeddingScanner │
│ └─ UsageStats.stats["embeddings"][sha256]++ │
│ │
│ Coverage: ~95% — all CLIPTextEncode/Flux/etc nodes │
│ │
│ Gap: Custom nodes that load embeddings programmatically │
│ without putting embedding:name in prompt text │
└─────────────────────────────────────────────────────────┘
+
↓ (future: enable Plan B when needed)
┌─────────────────────────────────────────────────────────┐
│ Plan B (未来 — monkey-patch) │
│ │
│ comfy/sd1_clip.py:load_embed() │
│ │ │
│ ▼ │
│ Monkey-patch intercepts EVERY embedding file load │
│ │ │
│ ├─ Records embedding_name + success/failure │
│ ├─ Associates with current prompt_id (via registry)│
│ └─ Feeds into UsageStats same as Plan A │
│ │
│ Coverage: 100% — catches ALL embedding loads │
│ │
│ Cost: Requires patching into ComfyUI internals │
│ (sd1_clip.py, sdxl_clip.py, some text_encoders) │
└─────────────────────────────────────────────────────────┘
```
## Plan B Detail — Monkey-patch `load_embed`
### Target Function
**`comfy.sd1_clip.load_embed(embedding_name, embedding_directory, embedding_size, embed_key=None)`**
at line 415 of `sd1_clip.py`.
This is the **single choke point** for all embedding file loads in ComfyUI. Every
CLIP variant (SD1, SDXL, SD3, Flux) calls this same function.
### Implementation Sketch
```python
# In metadata_collector/metadata_hook.py (or a new module)
import comfy.sd1_clip as sd1_clip
_original_load_embed = sd1_clip.load_embed
def _patched_load_embed(embedding_name, embedding_directory, embedding_size, embed_key=None):
result = _original_load_embed(
embedding_name, embedding_directory, embedding_size, embed_key
)
if result is not None:
_record_embedding_usage(embedding_name)
return result
sd1_clip.load_embed = _patched_load_embed
```
### Prompt ID Association
The challenge is associating the `load_embed` call with the current `prompt_id`.
Options:
1. **Thread-local / contextvar**: Store current `prompt_id` in a `contextvars.ContextVar`
that the metadata_collector sets at the start of each prompt execution.
2. **MetadataRegistry singleton**: The MetadataRegistry already has `current_prompt_id`.
The patch can read it directly since both run in the same thread.
3. **Lazy aggregation**: Instead of associating with prompt_id at load time, collect
all loaded embedding names in a global set during execution, then flush to
UsageStats after the prompt completes.
### Files to Patch
| File | Function | Coverage |
|------|----------|----------|
| `comfy/sd1_clip.py:415` | `load_embed()` | Primary — SD1.x, SDXL, SD3, Flux |
| `comfy/sdxl_clip.py` | Not needed (calls `sd1_clip.SDTokenizer`) | — |
| `comfy/text_encoders/sd3_clip.py` | Not needed (calls `sd1_clip.SDTokenizer`) | — |
| `comfy/text_encoders/flux.py` | Not needed (calls `sd1_clip.SDTokenizer`) | — |
The SD1 tokenizer is the base class for all CLIP variants' tokenizers, so patching
`load_embed` covers them all.
### Edge Cases
| Edge Case | Plan A | Plan B |
|-----------|--------|--------|
| `embedding:name` in CLIPTextEncode | ✅ | ✅ |
| `embedding:name` in CLIPTextEncodeFlux | ✅ | ✅ |
| `embedding:name` in PromptLM (LoRA Manager) | ✅ | ✅ |
| `embedding:name` in WAS_Text_to_Conditioning | ✅ | ✅ |
| Custom node that loads embedding programmatically | ❌ | ✅ |
| Embedding loaded multiple times in same prompt | ✅ (dedup via set) | ✅ (dedup via set) |
| Embedding file not found | N/A | ✅ (can log) |
| Embedding dimension mismatch | N/A | ✅ (can log) |
| Text encoder with non-standard tokenizer (LLaMA, T5...) | Partial | ✅ (if it calls load_embed) |
## Migration Path: Standalone → Hybrid
### Phase 1 — Plan A (当前状态)
- Prompt text parsing only
- No monkey-patching required
- Covers all standard workflows
### Phase 2 — Enable Plan B (未来工作)
1. Add monkey-patch of `load_embed` in `metadata_collector/metadata_hook.py` (alongside
the existing `_map_node_over_list` hook)
2. Collect loaded embedding names in a `set()` on the registry
3. In `UsageStats._process_embeddings()`, merge the Plan A results (from prompt text)
with the Plan B results (from the patch)
4. Add `prompt_data` field on MetadataRegistry to store loaded embeddings per prompt
### Deduplication
```python
# Merge Plan A + Plan B results in _process_embeddings
plan_a_names = extract_from_prompt_texts(prompts_data)
plan_b_names = registry.get_loaded_embeddings(prompt_id)
all_names = plan_a_names | plan_b_names
```
## Testing the Hybrid
| Scenario | What to verify |
|----------|---------------|
| Standard `embedding:name` in prompt | Plan A captures it |
| Embedding loaded by custom node script | Plan B captures it |
| Both paths fire for same embedding | No double-counting (dedup) |
| Embedding name resolves to hash | EmbeddingScanner.get_hash_by_filename works |
| No embedding scanner available | Graceful skip, no crash |
| Missing embedding file | Plan B logs warning, Plan A skips gracefully |
| Empty prompt | No crash, no entries |
| Standalone mode | Both plans disabled gracefully |
## Key Files Reference
| File | Role |
|------|------|
| `py/utils/usage_stats.py` | Core — `_process_embeddings()` for Plan A |
| `py/metadata_collector/constants.py` | `EMBEDDINGS` category constant |
| `py/metadata_collector/metadata_hook.py` | Future — monkey-patch for Plan B |
| `py/services/embedding_scanner.py` | Hash resolution service |
| `py/routes/stats_routes.py` | Already handles `usage_data.get('embeddings', {})` |
| `comfy/sd1_clip.py` (ComfyUI) | `load_embed()` — Plan B target |

View File

@@ -113,6 +113,7 @@
"replacePreview": "Vorschau ersetzen",
"copyCheckpointName": "Checkpoint-Name kopieren",
"copyEmbeddingName": "Embedding-Name kopieren",
"embeddingNameCopied": "Embedding-Syntax kopiert",
"sendCheckpointToWorkflow": "An ComfyUI senden",
"sendEmbeddingToWorkflow": "An ComfyUI senden"
},
@@ -1500,11 +1501,14 @@
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
"modelUpdated": "Modell im Workflow aktualisiert",
"modelFailed": "Fehler beim Aktualisieren des Modellknotens"
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
"embeddingAdded": "Embedding zum Workflow hinzugefügt",
"embeddingFailed": "Fehler beim Hinzufügen des Embeddings"
},
"nodeSelector": {
"recipe": "Rezept",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "Ersetzen",
"append": "Anhängen",
"selectTargetNode": "Zielknoten auswählen",

View File

@@ -113,6 +113,7 @@
"replacePreview": "Replace Preview",
"copyCheckpointName": "Copy checkpoint name",
"copyEmbeddingName": "Copy embedding name",
"embeddingNameCopied": "Embedding syntax copied",
"sendCheckpointToWorkflow": "Send to ComfyUI",
"sendEmbeddingToWorkflow": "Send to ComfyUI"
},
@@ -1500,11 +1501,14 @@
"noMatchingNodes": "No compatible nodes available in the current workflow",
"noTargetNodeSelected": "No target node selected",
"modelUpdated": "Model updated in workflow",
"modelFailed": "Failed to update model node"
"modelFailed": "Failed to update model node",
"embeddingAdded": "Embedding added to workflow",
"embeddingFailed": "Failed to add embedding"
},
"nodeSelector": {
"recipe": "Recipe",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "Replace",
"append": "Append",
"selectTargetNode": "Select target node",

View File

@@ -113,6 +113,7 @@
"replacePreview": "Reemplazar vista previa",
"copyCheckpointName": "Copiar nombre del checkpoint",
"copyEmbeddingName": "Copiar nombre del embedding",
"embeddingNameCopied": "Sintaxis de embedding copiada",
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
},
@@ -1500,11 +1501,14 @@
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
"modelFailed": "Error al actualizar nodo de modelo"
"modelFailed": "Error al actualizar nodo de modelo",
"embeddingAdded": "Embedding añadido al flujo de trabajo",
"embeddingFailed": "Error al añadir el embedding"
},
"nodeSelector": {
"recipe": "Receta",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "Reemplazar",
"append": "Añadir",
"selectTargetNode": "Seleccionar nodo de destino",

View File

@@ -113,6 +113,7 @@
"replacePreview": "Remplacer l'aperçu",
"copyCheckpointName": "Copier le nom du checkpoint",
"copyEmbeddingName": "Copier le nom de l'embedding",
"embeddingNameCopied": "Syntaxe dembedding copiée",
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
},
@@ -1500,11 +1501,14 @@
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
"modelUpdated": "Modèle mis à jour dans le workflow",
"modelFailed": "Échec de la mise à jour du nœud modèle"
"modelFailed": "Échec de la mise à jour du nœud modèle",
"embeddingAdded": "Embedding ajouté au workflow",
"embeddingFailed": "Échec de l'ajout de l'embedding"
},
"nodeSelector": {
"recipe": "Recipe",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "Remplacer",
"append": "Ajouter",
"selectTargetNode": "Sélectionner le nœud cible",

View File

@@ -113,6 +113,7 @@
"replacePreview": "החלף תצוגה מקדימה",
"copyCheckpointName": "העתק שם Checkpoint",
"copyEmbeddingName": "העתק שם Embedding",
"embeddingNameCopied": "תחביר Embedding הועתק",
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
},
@@ -1500,11 +1501,14 @@
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noTargetNodeSelected": "לא נבחר צומת יעד",
"modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל"
"modelFailed": "עדכון צומת המודל נכשל",
"embeddingAdded": "Embedding נוסף ל-workflow",
"embeddingFailed": "הוספת Embedding נכשלה"
},
"nodeSelector": {
"recipe": "מתכון",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "החלף",
"append": "הוסף",
"selectTargetNode": "בחר צומת יעד",

View File

@@ -113,6 +113,7 @@
"replacePreview": "プレビューを置換",
"copyCheckpointName": "checkpoint名をコピー",
"copyEmbeddingName": "embedding名をコピー",
"embeddingNameCopied": "Embedding構文をコピーしました",
"sendCheckpointToWorkflow": "ComfyUIに送信",
"sendEmbeddingToWorkflow": "ComfyUIに送信"
},
@@ -1500,11 +1501,14 @@
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noTargetNodeSelected": "ターゲットノードが選択されていません",
"modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました"
"modelFailed": "モデルノードの更新に失敗しました",
"embeddingAdded": "Embeddingをワークフローに追加しました",
"embeddingFailed": "Embeddingの追加に失敗しました"
},
"nodeSelector": {
"recipe": "レシピ",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "置換",
"append": "追加",
"selectTargetNode": "ターゲットノードを選択",

View File

@@ -113,6 +113,7 @@
"replacePreview": "미리보기 교체",
"copyCheckpointName": "Checkpoint 이름 복사",
"copyEmbeddingName": "Embedding 이름 복사",
"embeddingNameCopied": "Embedding 구문 복사됨",
"sendCheckpointToWorkflow": "ComfyUI로 전송",
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
},
@@ -1500,11 +1501,14 @@
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패"
"modelFailed": "모델 노드 업데이트 실패",
"embeddingAdded": "Embedding을 워크플로에 추가했습니다",
"embeddingFailed": "Embedding 추가 실패"
},
"nodeSelector": {
"recipe": "레시피",
"lora": "LoRA",
"embedding": "임베딩",
"replace": "교체",
"append": "추가",
"selectTargetNode": "대상 노드 선택",

View File

@@ -113,6 +113,7 @@
"replacePreview": "Заменить превью",
"copyCheckpointName": "Копировать имя checkpoint",
"copyEmbeddingName": "Копировать имя embedding",
"embeddingNameCopied": "Синтаксис embedding скопирован",
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
},
@@ -1500,11 +1501,14 @@
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noTargetNodeSelected": "Целевой узел не выбран",
"modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели"
"modelFailed": "Не удалось обновить узел модели",
"embeddingAdded": "Embedding добавлен в workflow",
"embeddingFailed": "Не удалось добавить embedding"
},
"nodeSelector": {
"recipe": "Рецепт",
"lora": "LoRA",
"embedding": "Эмбеддинг",
"replace": "Заменить",
"append": "Добавить",
"selectTargetNode": "Выберите целевой узел",

View File

@@ -113,6 +113,7 @@
"replacePreview": "替换预览",
"copyCheckpointName": "复制 Checkpoint 名称",
"copyEmbeddingName": "复制 Embedding 名称",
"embeddingNameCopied": "已复制 Embedding 语法",
"sendCheckpointToWorkflow": "发送到 ComfyUI",
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
},
@@ -1500,11 +1501,14 @@
"noMatchingNodes": "当前工作流中没有兼容的节点",
"noTargetNodeSelected": "未选择目标节点",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型节点失败"
"modelFailed": "更新模型节点失败",
"embeddingAdded": "Embedding 已追加到工作流",
"embeddingFailed": "添加 Embedding 失败"
},
"nodeSelector": {
"recipe": "配方",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "替换",
"append": "追加",
"selectTargetNode": "选择目标节点",

View File

@@ -113,6 +113,7 @@
"replacePreview": "更換預覽圖",
"copyCheckpointName": "複製檢查點名稱",
"copyEmbeddingName": "複製嵌入名稱",
"embeddingNameCopied": "已複製 Embedding 語法",
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
},
@@ -1500,11 +1501,14 @@
"noMatchingNodes": "目前工作流程中沒有相容的節點",
"noTargetNodeSelected": "未選擇目標節點",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型節點失敗"
"modelFailed": "更新模型節點失敗",
"embeddingAdded": "Embedding 已附加到工作流",
"embeddingFailed": "傳送 Embedding 到工作流失敗"
},
"nodeSelector": {
"recipe": "配方",
"lora": "LoRA",
"embedding": "Embedding",
"replace": "取代",
"append": "附加",
"selectTargetNode": "選擇目標節點",

View File

@@ -5,9 +5,10 @@ MODELS = "models"
PROMPTS = "prompts"
SAMPLING = "sampling"
LORAS = "loras"
EMBEDDINGS = "embeddings"
SIZE = "size"
IMAGES = "images"
IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes
# Complete list of categories to track
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES]
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, EMBEDDINGS, SIZE, IMAGES]

View File

@@ -3086,6 +3086,7 @@ class NodeRegistryHandler:
data = await request.json()
widget_name = data.get("widget_name")
value = data.get("value")
mode = data.get("mode", "replace")
node_ids = data.get("node_ids")
if not isinstance(widget_name, str) or not widget_name:
@@ -3133,6 +3134,7 @@ class NodeRegistryHandler:
"id": parsed_node_id,
"widget_name": widget_name,
"value": value,
"mode": mode,
}
if graph_identifier is not None:

View File

@@ -833,24 +833,52 @@ class RecipeManagementHandler:
)
user_edits: dict[str, Any] = {}
for key in ("title", "tags", "favorite"):
if key in old_recipe and old_recipe[key]:
for key in ("title", "tags", "favorite", "preview_nsfw_level"):
if key in old_recipe and old_recipe[key] is not None:
user_edits[key] = old_recipe[key]
if "tags" in user_edits and not isinstance(user_edits["tags"], list):
del user_edits["tags"]
old_created = old_recipe.get("created_date")
old_modified = old_recipe.get("modified")
old_file_path = old_recipe.get("file_path", "")
old_folder = os.path.dirname(old_file_path) if old_file_path else None
image_id = extract_civitai_image_id(source_path)
is_local_file = not image_id and os.path.isfile(source_path)
if not image_id and not is_local_file:
return web.json_response(
{
"success": False,
"error": (
"Recipe source is neither a valid CivitAI image URL "
"nor an accessible local file. "
"Use repair or manual import instead."
),
},
status=400,
)
if is_local_file:
return await self._do_reimport_from_local(
source_path,
recipe_scanner,
recipe_id=recipe_id,
target_dir=old_folder,
user_edits=user_edits,
old_title=old_recipe.get("title", ""),
)
async with self._import_semaphore:
import_response = await self._do_import_from_url(
source_path,
recipe_scanner,
target_dir=old_folder,
)
await self._persistence_service.delete_recipe(
recipe_scanner=recipe_scanner, recipe_id=recipe_id
)
async with self._import_semaphore:
import_response = await self._do_import_from_url(
source_path, recipe_scanner
)
body_bytes = import_response.body
if not body_bytes:
raise RuntimeError("Re-import returned an empty response")
@@ -872,24 +900,6 @@ class RecipeManagementHandler:
exc,
)
timestamp_updates: dict[str, Any] = {}
if old_created is not None:
timestamp_updates["created_date"] = old_created
if old_modified is not None:
timestamp_updates["modified"] = old_modified
if new_recipe_id and timestamp_updates:
try:
await recipe_scanner.update_recipe_metadata(
new_recipe_id, timestamp_updates
)
except Exception as exc:
self._logger.warning(
"Re-import succeeded but failed to preserve "
"timestamps for new recipe %s: %s",
new_recipe_id,
exc,
)
return web.json_response(
{
"success": True,
@@ -1662,6 +1672,9 @@ class RecipeManagementHandler:
self,
image_url: str,
recipe_scanner: Any,
*,
recipe_id: str | None = None,
target_dir: str | None = None,
) -> web.Response:
image_id = extract_civitai_image_id(image_url)
if not image_id:
@@ -1835,9 +1848,104 @@ class RecipeManagementHandler:
tags=[],
metadata=metadata,
extension=extension,
recipe_id=recipe_id,
target_dir=target_dir,
)
return web.json_response(result.payload, status=result.status)
async def _do_reimport_from_local(
self,
file_path: str,
recipe_scanner: Any,
*,
recipe_id: str,
target_dir: str | None,
user_edits: dict[str, Any],
old_title: str,
) -> web.Response:
"""Re-import a recipe from a local image file.
Reads the original source file, re-parses its EXIF metadata, saves a
fresh recipe, then deletes the old one.
"""
normalized = os.path.normpath(file_path)
if not os.path.isfile(normalized):
raise RecipeNotFoundError(
f"Source file no longer accessible: {normalized}"
)
with open(normalized, "rb") as fh:
image_bytes = fh.read()
extension = os.path.splitext(normalized)[1].lower() or ".png"
analysis_result = await self._analysis_service.analyze_local_image(
file_path=normalized,
recipe_scanner=recipe_scanner,
)
analysis_payload: dict[str, Any] = analysis_result.payload
gen_params = analysis_payload.get("gen_params") or {}
loras = analysis_payload.get("loras") or []
checkpoint = analysis_payload.get("checkpoint")
base_model = analysis_payload.get("base_model", "")
metadata: dict[str, Any] = {
"base_model": base_model,
"loras": loras,
"gen_params": gen_params,
"source_path": normalized,
}
if checkpoint:
metadata["checkpoint"] = checkpoint
prompt = (
gen_params.get("prompt")
or gen_params.get("positivePrompt")
or ""
)
name = " ".join(str(prompt).split()[:10]) if prompt else old_title
result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner,
image_bytes=image_bytes,
image_base64=analysis_payload.get("image_base64"),
name=name,
tags=[],
metadata=metadata,
extension=extension,
target_dir=target_dir,
)
await self._persistence_service.delete_recipe(
recipe_scanner=recipe_scanner, recipe_id=recipe_id
)
new_recipe_id = result.payload.get("recipe_id")
if new_recipe_id and user_edits:
try:
await self._persistence_service.update_recipe(
recipe_scanner=recipe_scanner,
recipe_id=new_recipe_id,
updates=user_edits,
)
except Exception as exc:
self._logger.warning(
"Re-import (local) succeeded but failed to carry over "
"user edits for recipe %s: %s",
new_recipe_id,
exc,
)
return web.json_response(
{
"success": True,
"old_recipe_id": recipe_id,
"recipe_id": new_recipe_id,
"source_path": normalized,
}
)
async def create_from_example(self, request: web.Request) -> web.Response:
"""Create a recipe from a model's example image using cached metadata.

View File

@@ -1,7 +1,6 @@
import os
import logging
import toml
import git
import zipfile
import shutil
import tempfile
@@ -225,7 +224,7 @@ class UpdateRoutes:
logger.debug("Could not close downloaded-version history database", exc_info=True)
# Skip settings.json, civitai, model cache and runtime cache folders
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups'])
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups', 'stats'])
# Extract ZIP to temp dir
with tempfile.TemporaryDirectory() as tmp_dir:
@@ -235,7 +234,7 @@ class UpdateRoutes:
extracted_root = next(os.scandir(tmp_dir)).path
# Copy files, skipping user data that should be preserved
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups'}
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups', 'stats'}
for item in os.listdir(extracted_root):
if item in skip_items:
continue
@@ -252,7 +251,7 @@ class UpdateRoutes:
# for ComfyUI Manager to work properly
tracking_info_file = os.path.join(plugin_root, '.tracking')
tracking_files = []
skip_tracked = {'civitai', 'wildcards', 'backups'}
skip_tracked = {'civitai', 'wildcards', 'backups', 'stats'}
for root, dirs, files in os.walk(extracted_root):
# Skip user data directories and their contents
rel_root = os.path.relpath(root, extracted_root)
@@ -357,6 +356,15 @@ class UpdateRoutes:
Returns:
tuple: (success, new_version)
"""
try:
import git
except ImportError:
logger.error(
"GitPython is not available: the git executable was not found in PATH. "
"Install git or set $GIT_PYTHON_GIT_EXECUTABLE to the git binary path."
)
return False, ""
try:
# Open the Git repository
repo = git.Repo(plugin_root)
@@ -453,6 +461,7 @@ class UpdateRoutes:
if not os.path.exists(os.path.join(plugin_root, '.git')):
return git_info
import git
repo = git.Repo(plugin_root)
commit = repo.head.commit
git_info['commit_hash'] = commit.hexsha

View File

@@ -141,6 +141,16 @@ class BackupService:
)
)
stats_path = os.path.join(get_settings_dir(create=True), "stats", "lora_manager_stats.json")
if os.path.exists(stats_path):
targets.append(
(
"usage_stats",
"stats/lora_manager_stats.json",
stats_path,
)
)
return targets
@staticmethod
@@ -348,6 +358,8 @@ class BackupService:
if kind == "model_update":
filename = os.path.basename(archive_member)
return str(Path(get_cache_file_path(CacheType.MODEL_UPDATE, create_dir=True)).parent / filename)
if kind == "usage_stats":
return os.path.join(get_settings_dir(create=True), "stats", "lora_manager_stats.json")
return None
async def create_auto_snapshot_if_due(self) -> Optional[dict[str, Any]]:

View File

@@ -49,8 +49,18 @@ class RecipePersistenceService:
tags: Iterable[str],
metadata: Optional[dict[str, Any]],
extension: str | None = None,
recipe_id: str | None = None,
target_dir: str | None = None,
) -> PersistenceResult:
"""Persist a user uploaded recipe."""
"""Persist a user uploaded recipe.
Args:
recipe_id: If provided, reuse this ID instead of generating a new
UUID. Used by re-import to preserve the original recipe identity.
target_dir: If provided, save recipe files to this directory instead
of the default recipes_dir. Used by re-import to preserve the
original folder location.
"""
missing_fields = []
if not name:
@@ -63,10 +73,10 @@ class RecipePersistenceService:
)
resolved_image_bytes = self._resolve_image_bytes(image_bytes, image_base64)
recipes_dir = recipe_scanner.recipes_dir
recipes_dir = target_dir or recipe_scanner.recipes_dir
os.makedirs(recipes_dir, exist_ok=True)
recipe_id = str(uuid.uuid4())
recipe_id = recipe_id or str(uuid.uuid4())
# Handle video formats by bypassing optimization and metadata embedding
is_video = extension in [".mp4", ".webm"]

View File

@@ -1,4 +1,5 @@
import os
import re
import json
import time
import asyncio
@@ -9,6 +10,7 @@ from typing import Dict, Set
from ..config import config
from ..services.service_registry import ServiceRegistry
from ..utils.settings_paths import get_settings_dir
# Check if running in standalone mode
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
@@ -16,14 +18,18 @@ standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.en
# Define constants locally to avoid dependency on conditional imports
MODELS = "models"
LORAS = "loras"
EMBEDDINGS = "embeddings"
PROMPTS = "prompts"
if not standalone_mode:
from ..metadata_collector.metadata_registry import MetadataRegistry
# Import constants from metadata_collector to ensure consistency, but we have fallbacks defined above
try:
from ..metadata_collector.constants import MODELS as _MODELS, LORAS as _LORAS
from ..metadata_collector.constants import MODELS as _MODELS, LORAS as _LORAS, EMBEDDINGS as _EMBEDDINGS, PROMPTS as _PROMPTS
MODELS = _MODELS
LORAS = _LORAS
EMBEDDINGS = _EMBEDDINGS
PROMPTS = _PROMPTS
except ImportError:
pass # Use the local definitions
@@ -65,6 +71,7 @@ class UsageStats:
self.stats = {
"checkpoints": {}, # sha256 -> { total: count, history: { date: count } }
"loras": {}, # sha256 -> { total: count, history: { date: count } }
"embeddings": {}, # sha256 -> { total: count, history: { date: count } }
"total_executions": 0,
"last_save_time": 0
}
@@ -77,6 +84,7 @@ class UsageStats:
# Load existing stats if available
self._stats_file_path = self._get_stats_file_path()
self._migrate_from_old_location()
self._load_stats()
# Save interval in seconds
@@ -89,14 +97,38 @@ class UsageStats:
logger.debug("Usage statistics tracker initialized")
def _get_stats_file_path(self) -> str:
"""Get the path to the stats JSON file"""
"""Get the path to the stats JSON file in the settings directory."""
settings_dir = get_settings_dir(create=True)
return os.path.join(settings_dir, "stats", self.STATS_FILENAME)
@staticmethod
def _get_old_stats_file_path() -> str:
"""Get the legacy stats file path in the first lora root directory."""
if not config.loras_roots or len(config.loras_roots) == 0:
# If no lora roots are available, we can't save stats
# This will be handled by the caller
raise RuntimeError("No LoRA root directories configured. Cannot initialize usage statistics.")
# Use the first lora root
return os.path.join(config.loras_roots[0], self.STATS_FILENAME)
return ""
return os.path.join(config.loras_roots[0], UsageStats.STATS_FILENAME)
def _migrate_from_old_location(self) -> None:
"""Migrate stats file from old location (first lora root) to new location (settings_dir/stats/)."""
new_path = self._stats_file_path
if os.path.exists(new_path):
return
old_path = self._get_old_stats_file_path()
if not old_path or not os.path.exists(old_path):
return
try:
os.makedirs(os.path.dirname(new_path), exist_ok=True)
shutil.copy2(old_path, new_path)
logger.info("Migrated usage stats from %s to %s", old_path, new_path)
try:
os.remove(old_path)
logger.info("Cleaned up old stats file: %s", old_path)
except Exception as e:
logger.warning("Failed to remove old stats file %s: %s", old_path, e)
except Exception as e:
logger.error("Failed to migrate usage stats from %s to %s: %s", old_path, new_path, e)
def _backup_old_stats(self):
"""Backup the old stats file before conversion"""
@@ -115,6 +147,7 @@ class UsageStats:
new_stats = {
"checkpoints": {},
"loras": {},
"embeddings": {},
"total_executions": old_stats.get("total_executions", 0),
"last_save_time": old_stats.get("last_save_time", time.time())
}
@@ -142,21 +175,27 @@ class UsageStats:
}
}
# Convert embedding stats (if present in old format)
if "embeddings" in old_stats and isinstance(old_stats["embeddings"], dict):
for hash_id, count in old_stats["embeddings"].items():
new_stats["embeddings"][hash_id] = {
"total": count,
"history": {
today: count
}
}
logger.info("Successfully converted stats from old format to new format with history")
return new_stats
def _is_old_format(self, stats):
"""Check if the stats are in the old format (direct count values)"""
# Check if any lora or checkpoint entry is a direct number instead of an object
if "loras" in stats and isinstance(stats["loras"], dict):
for hash_id, data in stats["loras"].items():
if isinstance(data, (int, float)):
return True
if "checkpoints" in stats and isinstance(stats["checkpoints"], dict):
for hash_id, data in stats["checkpoints"].items():
if isinstance(data, (int, float)):
return True
for category in ("loras", "checkpoints", "embeddings"):
if category in stats and isinstance(stats[category], dict):
for hash_id, data in stats[category].items():
if isinstance(data, (int, float)):
return True
return False
@@ -304,6 +343,10 @@ class UsageStats:
if LORAS in metadata and isinstance(metadata[LORAS], dict):
await self._process_loras(metadata[LORAS], today)
# Process embeddings — parse prompt text for embedding:name references
if PROMPTS in metadata and isinstance(metadata[PROMPTS], dict):
await self._process_embeddings(metadata[PROMPTS], today)
def _increment_usage_counter(self, category: str, stat_key: str, today_date: str) -> None:
"""Increment usage counters for a resolved stats key."""
if stat_key not in self.stats[category]:
@@ -510,6 +553,55 @@ class UsageStats:
except Exception as e:
logger.error(f"Error processing LoRA usage: {e}", exc_info=True)
@staticmethod
def _extract_embedding_names(prompt_text: str) -> set:
"""Parse embedding:name references from prompt text.
ComfyUI's SDTokenizer resolves ``embedding:<name>`` during tokenization
(see ``sd1_clip.py _try_get_embedding``). This mirrors the same pattern
to extract embedding file names from the captured prompt strings.
"""
if not prompt_text:
return set()
# Matches ``embedding:name`` where name is alphanumeric plus _ . - /
names = re.findall(r"embedding:([a-zA-Z0-9_.\-/]+)", prompt_text)
return set(names)
async def _process_embeddings(self, prompts_data, today_date):
"""Extract embedding usage from prompt texts and record it.
Iterates every prompt node's text field captured by the metadata
collector, extracts ``embedding:<name>`` references, resolves each
name to its SHA256 hash via the embedding scanner, and increments
usage counters.
"""
try:
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
if not embedding_scanner:
logger.warning("Embedding scanner not available for usage tracking")
return
seen_names = set()
for _node_id, prompt_data in prompts_data.items():
if not isinstance(prompt_data, dict):
continue
for text_field in ("text", "positive_text", "negative_text"):
text = prompt_data.get(text_field)
if isinstance(text, str):
seen_names.update(self._extract_embedding_names(text))
for emb_name in seen_names:
emb_hash = embedding_scanner.get_hash_by_filename(emb_name)
if emb_hash:
self._increment_usage_counter("embeddings", emb_hash, today_date)
else:
logger.debug(
"No hash found for embedding '%s', skipping usage tracking",
emb_name,
)
except Exception as e:
logger.error("Error processing embedding usage: %s", e, exc_info=True)
async def get_stats(self):
"""Get current usage statistics"""
return self.stats
@@ -522,6 +614,9 @@ class UsageStats:
elif model_type == "lora":
if sha256 in self.stats["loras"]:
return self.stats["loras"][sha256]["total"]
elif model_type == "embedding":
if sha256 in self.stats["embeddings"]:
return self.stats["embeddings"][sha256]["total"]
return 0
async def process_execution(self, prompt_id):

View File

@@ -213,6 +213,8 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
if (scrollSnapshot) {
await restoreScrollPosition(scrollSnapshot);
} else if (state.virtualScroller?.scrollContainer) {
state.virtualScroller.scrollContainer.scrollTop = 0;
}
return result;

View File

@@ -51,21 +51,33 @@ export class BulkContextMenu extends BaseContextMenu {
reimportMetadataItem.style.display = config.reimportMetadata ? 'flex' : 'none';
}
const isEmbeddings = currentModelType === 'embeddings';
if (sendToWorkflowAppendItem) {
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
}
if (sendToWorkflowReplaceItem) {
sendToWorkflowReplaceItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
sendToWorkflowReplaceItem.style.display = (config.sendToWorkflow && !isEmbeddings) ? 'flex' : 'none';
}
if (copyAllItem) {
copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
}
// Submenu parent visibility
// Submenu parent - for embeddings, collapse into a direct item (no replace choice)
const sendToWorkflowSubmenu = this.menu.querySelector('[data-has-submenu="send-to-workflow"]');
if (sendToWorkflowSubmenu) {
const hasWorkflowActions = config.sendToWorkflow || config.copyAll;
sendToWorkflowSubmenu.style.display = hasWorkflowActions ? 'flex' : 'none';
if (isEmbeddings && config.sendToWorkflow && !config.copyAll) {
sendToWorkflowSubmenu.classList.remove('has-submenu');
sendToWorkflowSubmenu.removeAttribute('data-has-submenu');
sendToWorkflowSubmenu.dataset.action = 'send-to-workflow-append';
const arrow = sendToWorkflowSubmenu.querySelector('.submenu-arrow');
if (arrow) arrow.style.display = 'none';
const submenu = sendToWorkflowSubmenu.querySelector('.context-submenu');
if (submenu) submenu.style.display = 'none';
sendToWorkflowSubmenu.style.display = 'flex';
} else {
sendToWorkflowSubmenu.style.display = hasWorkflowActions ? 'flex' : 'none';
}
}
if (refreshAllItem) {

View File

@@ -3,6 +3,7 @@ import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { moveManager } from '../../managers/MoveManager.js';
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
import { sendEmbeddingToWorkflow } from '../../utils/uiHelpers.js';
export class EmbeddingContextMenu extends BaseContextMenu {
constructor() {
@@ -51,6 +52,13 @@ export class EmbeddingContextMenu extends BaseContextMenu {
this.currentCard.querySelector('.fa-copy').click();
}
break;
case 'sendtoworkflow': {
const folder = this.currentCard.dataset.folder || '';
const name = this.currentCard.dataset.file_name || '';
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
sendEmbeddingToWorkflow(embeddingCode);
break;
}
case 'refresh-metadata':
// Refresh metadata from CivitAI
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);

View File

@@ -347,7 +347,7 @@ export class RecipeContextMenu extends BaseContextMenu {
state.loadingManager.hide();
showToast('toast.recipes.reimportSuccess', {}, 'success');
const { resetAndReload } = await import('../../api/recipeApi.js');
resetAndReload(false, { preserveScroll: true });
resetAndReload(false, { preserveScroll: false });
} else {
throw new Error(result.error || 'Re-import failed');
}

View File

@@ -1,4 +1,4 @@
import { showToast, openCivitai, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
import { showToast, openCivitai, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js';
@@ -216,6 +216,11 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
missingNodesMessage,
missingTargetMessage,
});
} else if (modelType === MODEL_TYPES.EMBEDDING) {
const folder = card.dataset.folder || '';
const name = card.dataset.file_name || '';
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
sendEmbeddingToWorkflow(embeddingCode);
} else {
showToast('modelCard.sendToWorkflow.checkpointNotImplemented', {}, 'info');
}
@@ -230,8 +235,11 @@ function handleCopyAction(card, modelType) {
const message = translate('modelCard.actions.checkpointNameCopied', {}, 'Checkpoint name copied');
copyToClipboard(checkpointName, message);
} else if (modelType === MODEL_TYPES.EMBEDDING) {
const embeddingName = card.dataset.file_name;
copyToClipboard(embeddingName, 'Embedding name copied');
const folder = card.dataset.folder || '';
const name = card.dataset.file_name || '';
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied');
copyToClipboard(embeddingCode, message);
}
}

View File

@@ -1,4 +1,4 @@
import { showToast, openCivitai, sendLoraToWorkflow, sendModelPathToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js';
import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, sendModelPathToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
import {
@@ -648,6 +648,10 @@ export async function showModelModal(model, modelType) {
if (modelType === 'checkpoints' && modelWithFullData.sub_type) {
activeModalElement.dataset.subType = modelWithFullData.sub_type;
}
// Store folder for embedding models
if (modelType === 'embeddings' && modelWithFullData.folder) {
activeModalElement.dataset.folder = modelWithFullData.folder;
}
}
updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
const versionsTabController = initVersionsTab({
@@ -1188,9 +1192,10 @@ async function handleSendToWorkflow(target, modelType) {
missingTargetMessage,
});
} else if (modelType === 'embeddings') {
// For Embedding: Send as LoRA syntax (embedding name only)
const embeddingSyntax = `<embed:${currentFileName}:1>`;
await sendLoraToWorkflow(embeddingSyntax, false, 'embedding');
const folder = modalElement?.dataset?.folder || '';
const name = currentFileName.replace(/\.[^.]+$/, '');
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
await sendEmbeddingToWorkflow(embeddingCode);
}
}

View File

@@ -1,5 +1,5 @@
import { state, getCurrentPageState } from '../state/index.js';
import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
import { showToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
@@ -47,7 +47,7 @@ export class BulkManager {
},
[MODEL_TYPES.EMBEDDING]: {
addTags: true,
sendToWorkflow: false,
sendToWorkflow: true,
copyAll: false,
refreshAll: true,
checkUpdates: true,
@@ -504,13 +504,17 @@ export class BulkManager {
}
async sendAllModelsToWorkflow(replaceMode = false) {
if (state.currentPageType !== MODEL_TYPES.LORA) {
showToast('toast.loras.sendOnlyForLoras', {}, 'warning');
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
if (state.selectedModels.size === 0) {
showToast('toast.loras.noLorasSelected', {}, 'warning');
if (state.currentPageType === MODEL_TYPES.EMBEDDING) {
return this._sendAllEmbeddingsToWorkflow();
}
if (state.currentPageType !== MODEL_TYPES.LORA) {
showToast('toast.loras.sendOnlyForLoras', {}, 'warning');
return;
}
@@ -542,6 +546,28 @@ export class BulkManager {
await sendLoraToWorkflow(loraSyntaxes.join(', '), replaceMode, 'lora');
}
async _sendAllEmbeddingsToWorkflow() {
const embeddingCodes = [];
for (const filepath of state.selectedModels) {
const escapedPath = CSS.escape(filepath);
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (card) {
const folder = card.dataset.folder || '';
const name = card.dataset.file_name || '';
const code = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
embeddingCodes.push(code);
}
}
if (embeddingCodes.length === 0) {
showToast('No valid embedding data found', {}, 'warning');
return;
}
const joinedCode = embeddingCodes.join(', ');
await sendEmbeddingToWorkflow(joinedCode);
}
showBulkDeleteModal() {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
@@ -724,14 +750,13 @@ export class BulkManager {
await progressUI.complete(
`Re-import complete: ${completed} re-imported, ${failed} failed`
);
const { resetAndReload: recipeResetAndReload } = await import('../api/recipeApi.js');
recipeResetAndReload(false, { preserveScroll: false });
this.clearSelection();
} else {
state.loadingManager.hide();
showToast('toast.recipes.reimportBulkFailed', {}, 'error');
}
const { resetAndReload: recipeResetAndReload } = await import('../api/recipeApi.js');
recipeResetAndReload(false, { preserveScroll: true });
this.clearSelection();
} catch (error) {
console.error('[reimportSelectedRecipes] outer catch:', error);
state.loadingManager.hide();

View File

@@ -866,6 +866,100 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
}
}
async function sendTextToNodes(nodeIds, nodesMap, text, mode, messages = {}) {
const {
successMessage = 'Updated workflow node',
failureMessage = 'Failed to update workflow node',
missingTargetMessage = 'No target node selected',
} = messages;
const targetIds = Array.isArray(nodeIds) ? nodeIds : [];
if (targetIds.length === 0) {
showToast(missingTargetMessage, {}, 'warning');
return false;
}
const references = targetIds
.map((nodeKey) => resolveNodeReference(nodeKey, nodesMap))
.filter((reference) => reference && reference.node_id !== undefined);
if (references.length === 0) {
showToast(missingTargetMessage, {}, 'warning');
return false;
}
try {
const response = await fetch('/api/lm/update-node-widget', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
widget_name: 'text',
value: text,
mode: mode || 'append',
node_ids: references,
}),
});
const result = await response.json();
if (result.success) {
showToast(successMessage, {}, 'success');
return true;
}
const errorMessage = result?.error || failureMessage;
showToast(errorMessage, {}, 'error');
return false;
} catch (error) {
console.error('Failed to send text to workflow:', error);
showToast(failureMessage, {}, 'error');
return false;
}
}
export async function sendEmbeddingToWorkflow(embeddingCode) {
const registry = await fetchWorkflowRegistry();
if (!registry) {
return false;
}
const textNodes = filterRegistryNodes(registry.nodes, (node) => {
if (!isNodeEnabled(node)) {
return false;
}
return node.capabilities?.has_text_widget === true;
});
const nodeKeys = Object.keys(textNodes);
if (nodeKeys.length === 0) {
showToast('uiHelpers.workflow.noMatchingNodes', {}, 'warning');
return false;
}
const messages = {
successMessage: translate('uiHelpers.workflow.embeddingAdded', {}, 'Embedding added to workflow'),
failureMessage: translate('uiHelpers.workflow.embeddingFailed', {}, 'Failed to add embedding'),
missingTargetMessage: translate('uiHelpers.workflow.noTargetNodeSelected', {}, 'No target node selected'),
};
const handleSend = (selectedNodeIds) =>
sendTextToNodes(selectedNodeIds, textNodes, embeddingCode, 'append', messages);
if (nodeKeys.length === 1) {
return await handleSend([nodeKeys[0]]);
}
const actionType = translate('uiHelpers.nodeSelector.embedding', {}, 'Embedding');
showNodeSelector(textNodes, {
actionType,
actionMode: '',
onSend: handleSend,
});
return true;
}
// Global variable to track active node selector state
let nodeSelectorState = {
isActive: false,

View File

@@ -16,6 +16,7 @@
<div class="context-menu-separator menu-section-break"></div>
<!-- Workflow -->
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
<div class="context-menu-item" data-action="sendtoworkflow"><i class="fas fa-paper-plane"></i> {{ t('checkpoints.contextMenu.sendToWorkflow') }}</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Media / Preview -->
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>

View File

@@ -754,6 +754,7 @@ async def test_update_node_widget_sends_payload():
"widget_name": "ckpt_name",
"value": "models/checkpoints/model.ckpt",
"graph_id": "root",
"mode": "replace",
},
)
]

View File

@@ -27,9 +27,12 @@ async def _finalize_usage_stats(tasks):
def _prepare_usage_stats(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, sleep_override=None):
UsageStats._instance = None
stats_root = tmp_path / "loras"
stats_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(usage_stats_module.config, "loras_roots", [str(stats_root)])
settings_dir = tmp_path / "settings"
settings_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(usage_stats_module, "get_settings_dir", lambda create=True: str(settings_dir))
loras_root = tmp_path / "loras"
loras_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(usage_stats_module.config, "loras_roots", [str(loras_root)])
created_tasks = []
real_create_task = usage_stats_module.asyncio.create_task
@@ -45,7 +48,7 @@ def _prepare_usage_stats(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, sle
monkeypatch.setattr(usage_stats_module.asyncio, "sleep", sleep_override)
stats = UsageStats()
return stats, created_tasks, stats_root
return stats, created_tasks, settings_dir, loras_root
async def test_usage_stats_converts_legacy_format(tmp_path, monkeypatch):
@@ -57,12 +60,15 @@ async def test_usage_stats_converts_legacy_format(tmp_path, monkeypatch):
}
UsageStats._instance = None
stats_root = tmp_path / "loras"
stats_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(usage_stats_module.config, "loras_roots", [str(stats_root)])
settings_dir = tmp_path / "settings"
settings_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(usage_stats_module, "get_settings_dir", lambda create=True: str(settings_dir))
loras_root = tmp_path / "loras"
loras_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(usage_stats_module.config, "loras_roots", [str(loras_root)])
stats_path = stats_root / UsageStats.STATS_FILENAME
stats_path.write_text(json.dumps(legacy_stats), encoding="utf-8")
old_stats_path = loras_root / UsageStats.STATS_FILENAME
old_stats_path.write_text(json.dumps(legacy_stats), encoding="utf-8")
created_tasks = []
real_create_task = usage_stats_module.asyncio.create_task
@@ -83,20 +89,23 @@ async def test_usage_stats_converts_legacy_format(tmp_path, monkeypatch):
assert converted["checkpoints"]["hash1"] == {"total": 3, "history": {today: 3}}
assert converted["loras"]["hash2"] == {"total": 5, "history": {today: 5}}
backup_path = stats_path.with_suffix(stats_path.suffix + UsageStats.BACKUP_SUFFIX)
new_stats_path = settings_dir / "stats" / UsageStats.STATS_FILENAME
assert new_stats_path.exists()
backup_path = new_stats_path.with_suffix(new_stats_path.suffix + UsageStats.BACKUP_SUFFIX)
assert backup_path.exists()
await _finalize_usage_stats(created_tasks)
async def test_usage_stats_save_stats_persists_file(tmp_path, monkeypatch):
stats, tasks, stats_root = _prepare_usage_stats(tmp_path, monkeypatch)
stats, tasks, settings_dir, _ = _prepare_usage_stats(tmp_path, monkeypatch)
stats.stats["total_executions"] = 4
saved = await stats.save_stats(force=True)
assert saved is True
stats_path = stats_root / UsageStats.STATS_FILENAME
stats_path = settings_dir / "stats" / UsageStats.STATS_FILENAME
persisted = json.loads(stats_path.read_text(encoding="utf-8"))
assert persisted["total_executions"] == 4
assert persisted["last_save_time"] == stats.stats["last_save_time"]
@@ -110,7 +119,7 @@ async def test_usage_stats_background_processor_handles_pending_prompts(tmp_path
async def fast_sleep(_seconds):
await real_sleep(0.01)
stats, tasks, _ = _prepare_usage_stats(tmp_path, monkeypatch, sleep_override=fast_sleep)
stats, tasks, _, _ = _prepare_usage_stats(tmp_path, monkeypatch, sleep_override=fast_sleep)
metadata_calls = []
# Use string literals directly to avoid dependency on conditional imports
@@ -155,7 +164,7 @@ async def test_usage_stats_background_processor_handles_pending_prompts(tmp_path
async def test_usage_stats_calculates_pending_checkpoint_hash_on_demand(tmp_path, monkeypatch):
stats, tasks, _ = _prepare_usage_stats(tmp_path, monkeypatch)
stats, tasks, _, _ = _prepare_usage_stats(tmp_path, monkeypatch)
metadata_payload = {
"models": {
@@ -195,7 +204,7 @@ async def test_usage_stats_calculates_pending_checkpoint_hash_on_demand(tmp_path
async def test_usage_stats_skips_failed_checkpoint_hash_retry(tmp_path, monkeypatch):
stats, tasks, _ = _prepare_usage_stats(tmp_path, monkeypatch)
stats, tasks, _, _ = _prepare_usage_stats(tmp_path, monkeypatch)
metadata_payload = {
"models": {
@@ -234,7 +243,7 @@ async def test_usage_stats_skips_failed_checkpoint_hash_retry(tmp_path, monkeypa
async def test_usage_stats_resolves_manually_copied_checkpoint_from_disk(tmp_path, monkeypatch):
stats, tasks, _ = _prepare_usage_stats(tmp_path, monkeypatch)
stats, tasks, _, _ = _prepare_usage_stats(tmp_path, monkeypatch)
checkpoints_root = tmp_path / "checkpoints"
checkpoints_root.mkdir()
@@ -273,7 +282,7 @@ async def test_usage_stats_resolves_manually_copied_checkpoint_from_disk(tmp_pat
async def test_usage_stats_skips_name_fallback_for_missing_lora_hash(tmp_path, monkeypatch):
stats, tasks, _ = _prepare_usage_stats(tmp_path, monkeypatch)
stats, tasks, _, _ = _prepare_usage_stats(tmp_path, monkeypatch)
metadata_payload = {
"models": {},
@@ -294,3 +303,79 @@ async def test_usage_stats_skips_name_fallback_for_missing_lora_hash(tmp_path, m
assert not any(key.startswith("name:") for key in stats.stats["loras"])
await _finalize_usage_stats(tasks)
async def test_usage_stats_migrates_from_old_location(tmp_path, monkeypatch):
settings_dir = tmp_path / "settings"
settings_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(usage_stats_module, "get_settings_dir", lambda create=True: str(settings_dir))
loras_root = tmp_path / "loras"
loras_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(usage_stats_module.config, "loras_roots", [str(loras_root)])
old_data = {
"checkpoints": {},
"loras": {"lora-hash": {"total": 3, "history": {"2025-01-01": 3}}},
"embeddings": {},
"total_executions": 3,
"last_save_time": 100.0,
}
old_path = loras_root / UsageStats.STATS_FILENAME
old_path.write_text(json.dumps(old_data), encoding="utf-8")
created_tasks = []
real_create_task = usage_stats_module.asyncio.create_task
def _track_task(coro):
task = real_create_task(coro)
created_tasks.append(task)
return task
monkeypatch.setattr(usage_stats_module.asyncio, "create_task", _track_task)
stats = UsageStats()
new_path = settings_dir / "stats" / UsageStats.STATS_FILENAME
assert new_path.exists(), "Stats file should be migrated to new location"
assert not old_path.exists(), "Old stats file should be removed after migration"
assert stats.stats["total_executions"] == 3
assert stats.stats["loras"]["lora-hash"]["total"] == 3
await _finalize_usage_stats(created_tasks)
async def test_usage_stats_uses_new_location_directly(tmp_path, monkeypatch):
settings_dir = tmp_path / "settings"
settings_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(usage_stats_module, "get_settings_dir", lambda create=True: str(settings_dir))
loras_root = tmp_path / "loras"
loras_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(usage_stats_module.config, "loras_roots", [str(loras_root)])
new_data = {
"checkpoints": {},
"loras": {},
"embeddings": {},
"total_executions": 7,
"last_save_time": 200.0,
}
new_path = settings_dir / "stats" / UsageStats.STATS_FILENAME
new_path.parent.mkdir(parents=True, exist_ok=True)
new_path.write_text(json.dumps(new_data), encoding="utf-8")
created_tasks = []
real_create_task = usage_stats_module.asyncio.create_task
def _track_task(coro):
task = real_create_task(coro)
created_tasks.append(task)
return task
monkeypatch.setattr(usage_stats_module.asyncio, "create_task", _track_task)
stats = UsageStats()
assert stats.stats["total_executions"] == 7
assert not loras_root.joinpath(UsageStats.STATS_FILENAME).exists()
await _finalize_usage_stats(created_tasks)

View File

@@ -10,6 +10,13 @@ const LORA_NODE_CLASSES = new Set([
const TARGET_WIDGET_NAMES = new Set(["ckpt_name", "unet_name"]);
// Node classes whose "text" widget is a prompt text input (not LoRA syntax, notes, etc.)
const TEXT_CAPABLE_CLASSES = new Set([
"Prompt (LoraManager)",
"Text (LoraManager)",
"CLIPTextEncode",
]);
app.registerExtension({
name: "LoraManager.WorkflowRegistry",
@@ -41,8 +48,9 @@ app.registerExtension({
const supportsLora = LORA_NODE_CLASSES.has(node.comfyClass);
const hasTargetWidget = widgetNames.some((name) => TARGET_WIDGET_NAMES.has(name));
const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass);
if (!supportsLora && !hasTargetWidget) {
if (!supportsLora && !hasTargetWidget && !hasTextWidget) {
continue;
}
@@ -65,6 +73,7 @@ app.registerExtension({
mode: node.mode,
capabilities: {
supports_lora: supportsLora,
has_text_widget: hasTextWidget,
widget_names: widgetNames,
},
});
@@ -95,6 +104,7 @@ app.registerExtension({
const graphId = message?.graph_id;
const widgetName = message?.widget_name;
const value = message?.value;
const mode = message?.mode ?? "replace";
if (nodeId == null || !widgetName) {
console.warn("LoRA Manager: invalid widget update payload", message);
@@ -127,15 +137,22 @@ app.registerExtension({
}
const widget = node.widgets[widgetIndex];
widget.value = value;
let newValue = value;
if (mode === "append") {
const separator = widget.value && widget.value.length > 0 ? " " : "";
newValue = widget.value + separator + value;
}
widget.value = newValue;
if (Array.isArray(node.widgets_values) && node.widgets_values.length > widgetIndex) {
node.widgets_values[widgetIndex] = value;
node.widgets_values[widgetIndex] = newValue;
}
if (typeof widget.callback === "function") {
try {
widget.callback(value);
widget.callback(newValue);
} catch (callbackError) {
console.error("LoRA Manager: widget callback failed", callbackError);
}