mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-06 16:36:45 -03:00
Compare commits
4 Commits
331889d872
...
ba3f15dbc6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba3f15dbc6 | ||
|
|
8dc2a2f76b | ||
|
|
316f17dd46 | ||
|
|
3dc10b1404 |
@@ -826,7 +826,8 @@
|
|||||||
"diffusion_model": "Diffusion Model"
|
"diffusion_model": "Diffusion Model"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben"
|
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben",
|
||||||
|
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1339,7 +1340,9 @@
|
|||||||
"recipeReplaced": "Rezept im Workflow ersetzt",
|
"recipeReplaced": "Rezept im Workflow ersetzt",
|
||||||
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
|
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
|
||||||
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
||||||
"noTargetNodeSelected": "Kein Zielknoten ausgewählt"
|
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
|
||||||
|
"modelUpdated": "Modell im Workflow aktualisiert",
|
||||||
|
"modelFailed": "Fehler beim Aktualisieren des Modellknotens"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Rezept",
|
"recipe": "Rezept",
|
||||||
@@ -1510,6 +1513,9 @@
|
|||||||
"nameUpdated": "Rezeptname erfolgreich aktualisiert",
|
"nameUpdated": "Rezeptname erfolgreich aktualisiert",
|
||||||
"tagsUpdated": "Rezept-Tags erfolgreich aktualisiert",
|
"tagsUpdated": "Rezept-Tags erfolgreich aktualisiert",
|
||||||
"sourceUrlUpdated": "Quell-URL erfolgreich aktualisiert",
|
"sourceUrlUpdated": "Quell-URL erfolgreich aktualisiert",
|
||||||
|
"promptUpdated": "Prompt erfolgreich aktualisiert",
|
||||||
|
"negativePromptUpdated": "Negativer Prompt erfolgreich aktualisiert",
|
||||||
|
"promptEditorHint": "Drücken Sie Enter zum Speichern, Shift+Enter für neue Zeile",
|
||||||
"noRecipeId": "Keine Rezept-ID verfügbar",
|
"noRecipeId": "Keine Rezept-ID verfügbar",
|
||||||
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
|
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
|
||||||
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",
|
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",
|
||||||
|
|||||||
@@ -826,7 +826,8 @@
|
|||||||
"diffusion_model": "Diffusion Model"
|
"diffusion_model": "Diffusion Model"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "Move to {otherType} Folder"
|
"moveToOtherTypeFolder": "Move to {otherType} Folder",
|
||||||
|
"sendToWorkflow": "Send to Workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1339,7 +1340,9 @@
|
|||||||
"recipeReplaced": "Recipe replaced in workflow",
|
"recipeReplaced": "Recipe replaced in workflow",
|
||||||
"recipeFailedToSend": "Failed to send recipe to workflow",
|
"recipeFailedToSend": "Failed to send recipe to workflow",
|
||||||
"noMatchingNodes": "No compatible nodes available in the current workflow",
|
"noMatchingNodes": "No compatible nodes available in the current workflow",
|
||||||
"noTargetNodeSelected": "No target node selected"
|
"noTargetNodeSelected": "No target node selected",
|
||||||
|
"modelUpdated": "Model updated in workflow",
|
||||||
|
"modelFailed": "Failed to update model node"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Recipe",
|
"recipe": "Recipe",
|
||||||
@@ -1510,6 +1513,9 @@
|
|||||||
"nameUpdated": "Recipe name updated successfully",
|
"nameUpdated": "Recipe name updated successfully",
|
||||||
"tagsUpdated": "Recipe tags updated successfully",
|
"tagsUpdated": "Recipe tags updated successfully",
|
||||||
"sourceUrlUpdated": "Source URL updated successfully",
|
"sourceUrlUpdated": "Source URL updated successfully",
|
||||||
|
"promptUpdated": "Prompt updated successfully",
|
||||||
|
"negativePromptUpdated": "Negative prompt updated successfully",
|
||||||
|
"promptEditorHint": "Press Enter to save, Shift+Enter for new line",
|
||||||
"noRecipeId": "No recipe ID available",
|
"noRecipeId": "No recipe ID available",
|
||||||
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
|
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
|
||||||
"copyFailed": "Error copying recipe syntax: {message}",
|
"copyFailed": "Error copying recipe syntax: {message}",
|
||||||
|
|||||||
@@ -826,7 +826,8 @@
|
|||||||
"diffusion_model": "Diffusion Model"
|
"diffusion_model": "Diffusion Model"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "Mover a la carpeta {otherType}"
|
"moveToOtherTypeFolder": "Mover a la carpeta {otherType}",
|
||||||
|
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1339,7 +1340,9 @@
|
|||||||
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
||||||
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
|
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
|
||||||
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
||||||
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino"
|
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
|
||||||
|
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
|
||||||
|
"modelFailed": "Error al actualizar nodo de modelo"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Receta",
|
"recipe": "Receta",
|
||||||
@@ -1510,6 +1513,9 @@
|
|||||||
"nameUpdated": "Nombre de receta actualizado exitosamente",
|
"nameUpdated": "Nombre de receta actualizado exitosamente",
|
||||||
"tagsUpdated": "Etiquetas de receta actualizadas exitosamente",
|
"tagsUpdated": "Etiquetas de receta actualizadas exitosamente",
|
||||||
"sourceUrlUpdated": "URL de origen actualizada exitosamente",
|
"sourceUrlUpdated": "URL de origen actualizada exitosamente",
|
||||||
|
"promptUpdated": "Prompt actualizado exitosamente",
|
||||||
|
"negativePromptUpdated": "Prompt negativo actualizado exitosamente",
|
||||||
|
"promptEditorHint": "Presiona Enter para guardar, Shift+Enter para nueva línea",
|
||||||
"noRecipeId": "No hay ID de receta disponible",
|
"noRecipeId": "No hay ID de receta disponible",
|
||||||
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}",
|
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}",
|
||||||
"copyFailed": "Error copiando sintaxis de receta: {message}",
|
"copyFailed": "Error copiando sintaxis de receta: {message}",
|
||||||
|
|||||||
@@ -826,7 +826,8 @@
|
|||||||
"diffusion_model": "Diffusion Model"
|
"diffusion_model": "Diffusion Model"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}"
|
"moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}",
|
||||||
|
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1339,7 +1340,9 @@
|
|||||||
"recipeReplaced": "Recipe remplacée dans le workflow",
|
"recipeReplaced": "Recipe remplacée dans le workflow",
|
||||||
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
|
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
|
||||||
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
||||||
"noTargetNodeSelected": "Aucun nœud cible sélectionné"
|
"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"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Recipe",
|
"recipe": "Recipe",
|
||||||
@@ -1510,6 +1513,9 @@
|
|||||||
"nameUpdated": "Nom de la recipe mis à jour avec succès",
|
"nameUpdated": "Nom de la recipe mis à jour avec succès",
|
||||||
"tagsUpdated": "Tags de la recipe mis à jour avec succès",
|
"tagsUpdated": "Tags de la recipe mis à jour avec succès",
|
||||||
"sourceUrlUpdated": "URL source mise à jour avec succès",
|
"sourceUrlUpdated": "URL source mise à jour avec succès",
|
||||||
|
"promptUpdated": "Prompt mis à jour avec succès",
|
||||||
|
"negativePromptUpdated": "Prompt négatif mis à jour avec succès",
|
||||||
|
"promptEditorHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne",
|
||||||
"noRecipeId": "Aucun ID de recipe disponible",
|
"noRecipeId": "Aucun ID de recipe disponible",
|
||||||
"sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}",
|
"sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}",
|
||||||
"copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}",
|
"copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}",
|
||||||
|
|||||||
@@ -826,7 +826,8 @@
|
|||||||
"diffusion_model": "Diffusion Model"
|
"diffusion_model": "Diffusion Model"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "העבר לתיקיית {otherType}"
|
"moveToOtherTypeFolder": "העבר לתיקיית {otherType}",
|
||||||
|
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1339,7 +1340,9 @@
|
|||||||
"recipeReplaced": "מתכון הוחלף ב-workflow",
|
"recipeReplaced": "מתכון הוחלף ב-workflow",
|
||||||
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
|
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
|
||||||
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
|
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
|
||||||
"noTargetNodeSelected": "לא נבחר צומת יעד"
|
"noTargetNodeSelected": "לא נבחר צומת יעד",
|
||||||
|
"modelUpdated": "מודל עודכן ב-workflow",
|
||||||
|
"modelFailed": "עדכון צומת המודל נכשל"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "מתכון",
|
"recipe": "מתכון",
|
||||||
@@ -1510,6 +1513,9 @@
|
|||||||
"nameUpdated": "שם המתכון עודכן בהצלחה",
|
"nameUpdated": "שם המתכון עודכן בהצלחה",
|
||||||
"tagsUpdated": "תגיות המתכון עודכנו בהצלחה",
|
"tagsUpdated": "תגיות המתכון עודכנו בהצלחה",
|
||||||
"sourceUrlUpdated": "כתובת ה-URL המקורית עודכנה בהצלחה",
|
"sourceUrlUpdated": "כתובת ה-URL המקורית עודכנה בהצלחה",
|
||||||
|
"promptUpdated": "הפרומפט עודכן בהצלחה",
|
||||||
|
"negativePromptUpdated": "הפרומפט השלילי עודכן בהצלחה",
|
||||||
|
"promptEditorHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
|
||||||
"noRecipeId": "אין מזהה מתכון זמין",
|
"noRecipeId": "אין מזהה מתכון זמין",
|
||||||
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
|
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
|
||||||
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
|
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
|
||||||
|
|||||||
@@ -826,7 +826,8 @@
|
|||||||
"diffusion_model": "Diffusion Model"
|
"diffusion_model": "Diffusion Model"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "{otherType} フォルダに移動"
|
"moveToOtherTypeFolder": "{otherType} フォルダに移動",
|
||||||
|
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1339,7 +1340,9 @@
|
|||||||
"recipeReplaced": "レシピがワークフローで置換されました",
|
"recipeReplaced": "レシピがワークフローで置換されました",
|
||||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
||||||
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
||||||
"noTargetNodeSelected": "ターゲットノードが選択されていません"
|
"noTargetNodeSelected": "ターゲットノードが選択されていません",
|
||||||
|
"modelUpdated": "モデルがワークフローで更新されました",
|
||||||
|
"modelFailed": "モデルノードの更新に失敗しました"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "レシピ",
|
"recipe": "レシピ",
|
||||||
@@ -1510,6 +1513,9 @@
|
|||||||
"nameUpdated": "レシピ名が正常に更新されました",
|
"nameUpdated": "レシピ名が正常に更新されました",
|
||||||
"tagsUpdated": "レシピタグが正常に更新されました",
|
"tagsUpdated": "レシピタグが正常に更新されました",
|
||||||
"sourceUrlUpdated": "ソースURLが正常に更新されました",
|
"sourceUrlUpdated": "ソースURLが正常に更新されました",
|
||||||
|
"promptUpdated": "プロンプトが正常に更新されました",
|
||||||
|
"negativePromptUpdated": "ネガティブプロンプトが正常に更新されました",
|
||||||
|
"promptEditorHint": "Enterキーで保存、Shift+Enterで改行",
|
||||||
"noRecipeId": "レシピIDが利用できません",
|
"noRecipeId": "レシピIDが利用できません",
|
||||||
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
|
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
|
||||||
"copyFailed": "レシピ構文のコピーエラー:{message}",
|
"copyFailed": "レシピ構文のコピーエラー:{message}",
|
||||||
|
|||||||
@@ -826,7 +826,8 @@
|
|||||||
"diffusion_model": "Diffusion Model"
|
"diffusion_model": "Diffusion Model"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "{otherType} 폴더로 이동"
|
"moveToOtherTypeFolder": "{otherType} 폴더로 이동",
|
||||||
|
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1339,7 +1340,9 @@
|
|||||||
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
||||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
||||||
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
||||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다"
|
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
|
||||||
|
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
||||||
|
"modelFailed": "모델 노드 업데이트 실패"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "레시피",
|
"recipe": "레시피",
|
||||||
@@ -1510,6 +1513,9 @@
|
|||||||
"nameUpdated": "레시피 이름이 성공적으로 업데이트되었습니다",
|
"nameUpdated": "레시피 이름이 성공적으로 업데이트되었습니다",
|
||||||
"tagsUpdated": "레시피 태그가 성공적으로 업데이트되었습니다",
|
"tagsUpdated": "레시피 태그가 성공적으로 업데이트되었습니다",
|
||||||
"sourceUrlUpdated": "소스 URL이 성공적으로 업데이트되었습니다",
|
"sourceUrlUpdated": "소스 URL이 성공적으로 업데이트되었습니다",
|
||||||
|
"promptUpdated": "프롬프트가 성공적으로 업데이트되었습니다",
|
||||||
|
"negativePromptUpdated": "네거티브 프롬프트가 성공적으로 업데이트되었습니다",
|
||||||
|
"promptEditorHint": "Enter 키를 눌러 저장, Shift+Enter로 새 줄",
|
||||||
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
|
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
|
||||||
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
|
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
|
||||||
"copyFailed": "레시피 문법 복사 오류: {message}",
|
"copyFailed": "레시피 문법 복사 오류: {message}",
|
||||||
|
|||||||
@@ -826,7 +826,8 @@
|
|||||||
"diffusion_model": "Diffusion Model"
|
"diffusion_model": "Diffusion Model"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "Переместить в папку {otherType}"
|
"moveToOtherTypeFolder": "Переместить в папку {otherType}",
|
||||||
|
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1339,7 +1340,9 @@
|
|||||||
"recipeReplaced": "Рецепт заменён в workflow",
|
"recipeReplaced": "Рецепт заменён в workflow",
|
||||||
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
|
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
|
||||||
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
||||||
"noTargetNodeSelected": "Целевой узел не выбран"
|
"noTargetNodeSelected": "Целевой узел не выбран",
|
||||||
|
"modelUpdated": "Модель обновлена в workflow",
|
||||||
|
"modelFailed": "Не удалось обновить узел модели"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Рецепт",
|
"recipe": "Рецепт",
|
||||||
@@ -1510,6 +1513,9 @@
|
|||||||
"nameUpdated": "Название рецепта успешно обновлено",
|
"nameUpdated": "Название рецепта успешно обновлено",
|
||||||
"tagsUpdated": "Теги рецепта успешно обновлены",
|
"tagsUpdated": "Теги рецепта успешно обновлены",
|
||||||
"sourceUrlUpdated": "Исходный URL успешно обновлен",
|
"sourceUrlUpdated": "Исходный URL успешно обновлен",
|
||||||
|
"promptUpdated": "Промпт успешно обновлён",
|
||||||
|
"negativePromptUpdated": "Негативный промпт успешно обновлён",
|
||||||
|
"promptEditorHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
|
||||||
"noRecipeId": "ID рецепта недоступен",
|
"noRecipeId": "ID рецепта недоступен",
|
||||||
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
|
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
|
||||||
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
|
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
|
||||||
|
|||||||
@@ -826,7 +826,8 @@
|
|||||||
"diffusion_model": "Diffusion Model"
|
"diffusion_model": "Diffusion Model"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹"
|
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹",
|
||||||
|
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1339,7 +1340,9 @@
|
|||||||
"recipeReplaced": "配方已替换到工作流",
|
"recipeReplaced": "配方已替换到工作流",
|
||||||
"recipeFailedToSend": "发送配方到工作流失败",
|
"recipeFailedToSend": "发送配方到工作流失败",
|
||||||
"noMatchingNodes": "当前工作流中没有兼容的节点",
|
"noMatchingNodes": "当前工作流中没有兼容的节点",
|
||||||
"noTargetNodeSelected": "未选择目标节点"
|
"noTargetNodeSelected": "未选择目标节点",
|
||||||
|
"modelUpdated": "模型已更新到工作流",
|
||||||
|
"modelFailed": "更新模型节点失败"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "配方",
|
"recipe": "配方",
|
||||||
@@ -1510,6 +1513,9 @@
|
|||||||
"nameUpdated": "配方名称更新成功",
|
"nameUpdated": "配方名称更新成功",
|
||||||
"tagsUpdated": "配方标签更新成功",
|
"tagsUpdated": "配方标签更新成功",
|
||||||
"sourceUrlUpdated": "来源 URL 更新成功",
|
"sourceUrlUpdated": "来源 URL 更新成功",
|
||||||
|
"promptUpdated": "提示词更新成功",
|
||||||
|
"negativePromptUpdated": "负面提示词更新成功",
|
||||||
|
"promptEditorHint": "按 Enter 保存,Shift+Enter 换行",
|
||||||
"noRecipeId": "无配方 ID",
|
"noRecipeId": "无配方 ID",
|
||||||
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
|
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
|
||||||
"copyFailed": "复制配方语法出错:{message}",
|
"copyFailed": "复制配方语法出错:{message}",
|
||||||
|
|||||||
@@ -826,7 +826,8 @@
|
|||||||
"diffusion_model": "Diffusion Model"
|
"diffusion_model": "Diffusion Model"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾"
|
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾",
|
||||||
|
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1339,7 +1340,9 @@
|
|||||||
"recipeReplaced": "配方已取代於工作流",
|
"recipeReplaced": "配方已取代於工作流",
|
||||||
"recipeFailedToSend": "傳送配方到工作流失敗",
|
"recipeFailedToSend": "傳送配方到工作流失敗",
|
||||||
"noMatchingNodes": "目前工作流程中沒有相容的節點",
|
"noMatchingNodes": "目前工作流程中沒有相容的節點",
|
||||||
"noTargetNodeSelected": "未選擇目標節點"
|
"noTargetNodeSelected": "未選擇目標節點",
|
||||||
|
"modelUpdated": "模型已更新到工作流",
|
||||||
|
"modelFailed": "更新模型節點失敗"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "配方",
|
"recipe": "配方",
|
||||||
@@ -1510,6 +1513,9 @@
|
|||||||
"nameUpdated": "配方名稱已更新",
|
"nameUpdated": "配方名稱已更新",
|
||||||
"tagsUpdated": "配方標籤已更新",
|
"tagsUpdated": "配方標籤已更新",
|
||||||
"sourceUrlUpdated": "來源網址已更新",
|
"sourceUrlUpdated": "來源網址已更新",
|
||||||
|
"promptUpdated": "提示詞更新成功",
|
||||||
|
"negativePromptUpdated": "負面提示詞更新成功",
|
||||||
|
"promptEditorHint": "按 Enter 儲存,Shift+Enter 換行",
|
||||||
"noRecipeId": "無配方 ID",
|
"noRecipeId": "無配方 ID",
|
||||||
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
|
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
|
||||||
"copyFailed": "複製配方語法錯誤:{message}",
|
"copyFailed": "複製配方語法錯誤:{message}",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
"height",
|
"height",
|
||||||
"Model",
|
"Model",
|
||||||
"Model hash",
|
"Model hash",
|
||||||
|
"modelVersionIds",
|
||||||
)
|
)
|
||||||
return any(key in payload for key in civitai_image_fields)
|
return any(key in payload for key in civitai_image_fields)
|
||||||
|
|
||||||
@@ -429,6 +430,65 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
result["loras"].append(lora_entry)
|
result["loras"].append(lora_entry)
|
||||||
|
|
||||||
|
# Process modelVersionIds from Civitai image API
|
||||||
|
# These are model version IDs returned at root level when meta doesn't contain resources
|
||||||
|
if "modelVersionIds" in metadata and isinstance(
|
||||||
|
metadata["modelVersionIds"], list
|
||||||
|
):
|
||||||
|
for version_id in metadata["modelVersionIds"]:
|
||||||
|
version_id_str = str(version_id)
|
||||||
|
|
||||||
|
# Skip if we've already added this LoRA by version ID
|
||||||
|
if version_id_str in added_loras:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Initialize lora entry with version ID
|
||||||
|
lora_entry = {
|
||||||
|
"id": version_id,
|
||||||
|
"modelId": 0,
|
||||||
|
"name": "Unknown LoRA",
|
||||||
|
"version": "",
|
||||||
|
"type": "lora",
|
||||||
|
"weight": 1.0,
|
||||||
|
"existsLocally": False,
|
||||||
|
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
||||||
|
"baseModel": "",
|
||||||
|
"size": 0,
|
||||||
|
"downloadUrl": "",
|
||||||
|
"isDeleted": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch model info from Civitai
|
||||||
|
if metadata_provider and version_id_str:
|
||||||
|
try:
|
||||||
|
civitai_info = (
|
||||||
|
await metadata_provider.get_model_version_info(
|
||||||
|
version_id_str
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
|
lora_entry,
|
||||||
|
civitai_info,
|
||||||
|
recipe_scanner,
|
||||||
|
base_model_counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
if populated_entry is None:
|
||||||
|
continue # Skip invalid LoRA types
|
||||||
|
|
||||||
|
lora_entry = populated_entry
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error fetching Civitai info for model version {version_id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track this LoRA for deduplication
|
||||||
|
if version_id_str:
|
||||||
|
added_loras[version_id_str] = len(result["loras"])
|
||||||
|
|
||||||
|
result["loras"].append(lora_entry)
|
||||||
|
|
||||||
# If we found LoRA hashes in the metadata but haven't already
|
# If we found LoRA hashes in the metadata but haven't already
|
||||||
# populated entries for them, fall back to creating LoRAs from
|
# populated entries for them, fall back to creating LoRAs from
|
||||||
# the hashes section. Some Civitai image responses only include
|
# the hashes section. Some Civitai image responses only include
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class RecipeHandlerSet:
|
|||||||
"bulk_delete": self.management.bulk_delete,
|
"bulk_delete": self.management.bulk_delete,
|
||||||
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
||||||
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
||||||
|
"get_recipes_for_checkpoint": self.query.get_recipes_for_checkpoint,
|
||||||
"scan_recipes": self.query.scan_recipes,
|
"scan_recipes": self.query.scan_recipes,
|
||||||
"move_recipe": self.management.move_recipe,
|
"move_recipe": self.management.move_recipe,
|
||||||
"repair_recipes": self.management.repair_recipes,
|
"repair_recipes": self.management.repair_recipes,
|
||||||
@@ -218,6 +219,7 @@ class RecipeListingHandler:
|
|||||||
filters["tags"] = tag_filters
|
filters["tags"] = tag_filters
|
||||||
|
|
||||||
lora_hash = request.query.get("lora_hash")
|
lora_hash = request.query.get("lora_hash")
|
||||||
|
checkpoint_hash = request.query.get("checkpoint_hash")
|
||||||
|
|
||||||
result = await recipe_scanner.get_paginated_data(
|
result = await recipe_scanner.get_paginated_data(
|
||||||
page=page,
|
page=page,
|
||||||
@@ -227,6 +229,7 @@ class RecipeListingHandler:
|
|||||||
filters=filters,
|
filters=filters,
|
||||||
search_options=search_options,
|
search_options=search_options,
|
||||||
lora_hash=lora_hash,
|
lora_hash=lora_hash,
|
||||||
|
checkpoint_hash=checkpoint_hash,
|
||||||
folder=folder,
|
folder=folder,
|
||||||
recursive=recursive,
|
recursive=recursive,
|
||||||
)
|
)
|
||||||
@@ -423,6 +426,28 @@ class RecipeQueryHandler:
|
|||||||
self._logger.error("Error getting recipes for Lora: %s", exc)
|
self._logger.error("Error getting recipes for Lora: %s", exc)
|
||||||
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_recipes_for_checkpoint(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")
|
||||||
|
|
||||||
|
checkpoint_hash = request.query.get("hash")
|
||||||
|
if not checkpoint_hash:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Checkpoint hash is required"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
matching_recipes = await recipe_scanner.get_recipes_for_checkpoint(
|
||||||
|
checkpoint_hash
|
||||||
|
)
|
||||||
|
return web.json_response({"success": True, "recipes": matching_recipes})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error getting recipes for checkpoint: %s", exc)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def scan_recipes(self, request: web.Request) -> web.Response:
|
async def scan_recipes(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
"POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"
|
"POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"
|
||||||
),
|
),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
|
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
|
||||||
|
RouteDefinition(
|
||||||
|
"GET", "/api/lm/recipes/for-checkpoint", "get_recipes_for_checkpoint"
|
||||||
|
),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
|
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
||||||
|
|||||||
@@ -1615,6 +1615,9 @@ class RecipeScanner:
|
|||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""Coerce legacy or malformed checkpoint entries into a dict."""
|
"""Coerce legacy or malformed checkpoint entries into a dict."""
|
||||||
|
|
||||||
|
if checkpoint_raw is None:
|
||||||
|
return None
|
||||||
|
|
||||||
if isinstance(checkpoint_raw, dict):
|
if isinstance(checkpoint_raw, dict):
|
||||||
return dict(checkpoint_raw)
|
return dict(checkpoint_raw)
|
||||||
|
|
||||||
@@ -1632,9 +1635,6 @@ class RecipeScanner:
|
|||||||
"file_name": file_name,
|
"file_name": file_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warning(
|
|
||||||
"Unexpected checkpoint payload type %s", type(checkpoint_raw).__name__
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _enrich_checkpoint_entry(self, checkpoint: Dict[str, Any]) -> Dict[str, Any]:
|
def _enrich_checkpoint_entry(self, checkpoint: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -1790,6 +1790,7 @@ class RecipeScanner:
|
|||||||
filters: dict = None,
|
filters: dict = None,
|
||||||
search_options: dict = None,
|
search_options: dict = None,
|
||||||
lora_hash: str = None,
|
lora_hash: str = None,
|
||||||
|
checkpoint_hash: str = None,
|
||||||
bypass_filters: bool = True,
|
bypass_filters: bool = True,
|
||||||
folder: str | None = None,
|
folder: str | None = None,
|
||||||
recursive: bool = True,
|
recursive: bool = True,
|
||||||
@@ -1804,7 +1805,8 @@ class RecipeScanner:
|
|||||||
filters: Dictionary of filters to apply
|
filters: Dictionary of filters to apply
|
||||||
search_options: Dictionary of search options to apply
|
search_options: Dictionary of search options to apply
|
||||||
lora_hash: Optional SHA256 hash of a LoRA to filter recipes by
|
lora_hash: Optional SHA256 hash of a LoRA to filter recipes by
|
||||||
bypass_filters: If True, ignore other filters when a lora_hash is provided
|
checkpoint_hash: Optional SHA256 hash of a checkpoint to filter recipes by
|
||||||
|
bypass_filters: If True, ignore other filters when a hash filter is provided
|
||||||
folder: Optional folder filter relative to recipes directory
|
folder: Optional folder filter relative to recipes directory
|
||||||
recursive: Whether to include recipes in subfolders of the selected folder
|
recursive: Whether to include recipes in subfolders of the selected folder
|
||||||
"""
|
"""
|
||||||
@@ -1852,9 +1854,23 @@ class RecipeScanner:
|
|||||||
# Skip other filters if bypass_filters is True
|
# Skip other filters if bypass_filters is True
|
||||||
pass
|
pass
|
||||||
# Otherwise continue with normal filtering after applying LoRA hash filter
|
# Otherwise continue with normal filtering after applying LoRA hash filter
|
||||||
|
elif checkpoint_hash:
|
||||||
|
normalized_checkpoint_hash = checkpoint_hash.lower()
|
||||||
|
filtered_data = [
|
||||||
|
item
|
||||||
|
for item in filtered_data
|
||||||
|
if isinstance(item.get("checkpoint"), dict)
|
||||||
|
and (item["checkpoint"].get("hash", "") or "").lower()
|
||||||
|
== normalized_checkpoint_hash
|
||||||
|
]
|
||||||
|
|
||||||
# Skip further filtering if we're only filtering by LoRA hash with bypass enabled
|
if bypass_filters:
|
||||||
if not (lora_hash and bypass_filters):
|
pass
|
||||||
|
|
||||||
|
has_hash_filter = bool(lora_hash or checkpoint_hash)
|
||||||
|
|
||||||
|
# Skip further filtering if we're only filtering by model hash with bypass enabled
|
||||||
|
if not (has_hash_filter and bypass_filters):
|
||||||
# Apply folder filter before other criteria
|
# Apply folder filter before other criteria
|
||||||
if folder is not None:
|
if folder is not None:
|
||||||
normalized_folder = folder.strip("/")
|
normalized_folder = folder.strip("/")
|
||||||
@@ -2334,6 +2350,38 @@ class RecipeScanner:
|
|||||||
|
|
||||||
return matching_recipes
|
return matching_recipes
|
||||||
|
|
||||||
|
async def get_recipes_for_checkpoint(
|
||||||
|
self, checkpoint_hash: str
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Return recipes that reference a given checkpoint hash."""
|
||||||
|
|
||||||
|
if not checkpoint_hash:
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalized_hash = checkpoint_hash.lower()
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
matching_recipes: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for recipe in cache.raw_data:
|
||||||
|
checkpoint = self._normalize_checkpoint_entry(recipe.get("checkpoint"))
|
||||||
|
if not checkpoint:
|
||||||
|
continue
|
||||||
|
|
||||||
|
enriched_checkpoint = self._enrich_checkpoint_entry(dict(checkpoint))
|
||||||
|
if (enriched_checkpoint.get("hash") or "").lower() != normalized_hash:
|
||||||
|
continue
|
||||||
|
|
||||||
|
recipe_copy = {**recipe}
|
||||||
|
recipe_copy["checkpoint"] = enriched_checkpoint
|
||||||
|
recipe_copy["loras"] = [
|
||||||
|
self._enrich_lora_entry(dict(entry))
|
||||||
|
for entry in recipe.get("loras", [])
|
||||||
|
]
|
||||||
|
recipe_copy["file_url"] = self._format_file_url(recipe.get("file_path"))
|
||||||
|
matching_recipes.append(recipe_copy)
|
||||||
|
|
||||||
|
return matching_recipes
|
||||||
|
|
||||||
async def get_recipe_syntax_tokens(self, recipe_id: str) -> List[str]:
|
async def get_recipe_syntax_tokens(self, recipe_id: str) -> List[str]:
|
||||||
"""Build LoRA syntax tokens for a recipe."""
|
"""Build LoRA syntax tokens for a recipe."""
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ class RecipeAnalysisService:
|
|||||||
):
|
):
|
||||||
metadata = metadata["meta"]
|
metadata = metadata["meta"]
|
||||||
|
|
||||||
|
# Include modelVersionIds from root level if available
|
||||||
|
# Civitai API returns modelVersionIds at root level, not in meta
|
||||||
|
model_version_ids = image_info.get("modelVersionIds")
|
||||||
|
if model_version_ids and isinstance(metadata, dict):
|
||||||
|
metadata["modelVersionIds"] = model_version_ids
|
||||||
|
|
||||||
# Validate that metadata contains meaningful recipe fields
|
# Validate that metadata contains meaningful recipe fields
|
||||||
# If not, treat as None to trigger EXIF extraction from downloaded image
|
# If not, treat as None to trigger EXIF extraction from downloaded image
|
||||||
if isinstance(metadata, dict) and not self._has_recipe_fields(metadata):
|
if isinstance(metadata, dict) and not self._has_recipe_fields(metadata):
|
||||||
|
|||||||
@@ -173,11 +173,23 @@ class RecipePersistenceService:
|
|||||||
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
|
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
|
||||||
"""Update persisted metadata for a recipe."""
|
"""Update persisted metadata for a recipe."""
|
||||||
|
|
||||||
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level", "favorite")):
|
allowed_fields = (
|
||||||
|
"title",
|
||||||
|
"tags",
|
||||||
|
"source_path",
|
||||||
|
"preview_nsfw_level",
|
||||||
|
"favorite",
|
||||||
|
"gen_params",
|
||||||
|
)
|
||||||
|
|
||||||
|
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)"
|
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "gen_params" in updates and not isinstance(updates["gen_params"], dict):
|
||||||
|
raise RecipeValidationError("gen_params must be an object")
|
||||||
|
|
||||||
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
|
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
|
||||||
if not success:
|
if not success:
|
||||||
raise RecipeNotFoundError("Recipe not found or update failed")
|
raise RecipeNotFoundError("Recipe not found or update failed")
|
||||||
|
|||||||
@@ -424,6 +424,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.param-header label {
|
.param-header label {
|
||||||
@@ -431,7 +432,14 @@
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-btn {
|
.param-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn,
|
||||||
|
.edit-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -442,7 +450,8 @@
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-btn:hover {
|
.copy-btn:hover,
|
||||||
|
.edit-btn:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
}
|
}
|
||||||
@@ -461,6 +470,48 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.param-content.hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-content.is-placeholder {
|
||||||
|
color: color-mix(in oklch, var(--text-color), transparent 35%);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-editor {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-editor.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-textarea {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 140px;
|
||||||
|
resize: vertical;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-editor-hint {
|
||||||
|
font-size: 0.78em;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: color-mix(in oklch, var(--text-color), transparent 35%);
|
||||||
|
}
|
||||||
|
|
||||||
/* Other Parameters */
|
/* Other Parameters */
|
||||||
.other-params {
|
.other-params {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
|||||||
if (pageState.customFilter?.active && pageState.customFilter?.loraHash) {
|
if (pageState.customFilter?.active && pageState.customFilter?.loraHash) {
|
||||||
params.append('lora_hash', pageState.customFilter.loraHash);
|
params.append('lora_hash', pageState.customFilter.loraHash);
|
||||||
params.append('bypass_filters', 'true');
|
params.append('bypass_filters', 'true');
|
||||||
|
} else if (pageState.customFilter?.active && pageState.customFilter?.checkpointHash) {
|
||||||
|
params.append('checkpoint_hash', pageState.customFilter.checkpointHash);
|
||||||
|
params.append('bypass_filters', 'true');
|
||||||
} else {
|
} else {
|
||||||
// Normal filtering logic
|
// Normal filtering logic
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js'
|
|||||||
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
||||||
import { moveManager } from '../../managers/MoveManager.js';
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
import { i18n } from '../../i18n/index.js';
|
import { i18n } from '../../i18n/index.js';
|
||||||
|
import { sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||||||
|
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||||
|
|
||||||
export class CheckpointContextMenu extends BaseContextMenu {
|
export class CheckpointContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -60,6 +62,10 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
|||||||
this.currentCard.querySelector('.fa-copy').click();
|
this.currentCard.querySelector('.fa-copy').click();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'sendworkflow':
|
||||||
|
// Send checkpoint to workflow (always replace mode)
|
||||||
|
this.sendCheckpointToWorkflow();
|
||||||
|
break;
|
||||||
case 'refresh-metadata':
|
case 'refresh-metadata':
|
||||||
// Refresh metadata from CivitAI
|
// Refresh metadata from CivitAI
|
||||||
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||||
@@ -79,6 +85,52 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendCheckpointToWorkflow() {
|
||||||
|
const modelPath = this.currentCard.dataset.filepath;
|
||||||
|
if (!modelPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtype = (this.currentCard.dataset.sub_type || 'checkpoint').toLowerCase();
|
||||||
|
const isDiffusionModel = subtype === 'diffusion_model';
|
||||||
|
const widgetName = isDiffusionModel ? 'unet_name' : 'ckpt_name';
|
||||||
|
const actionTypeText = i18n.t(
|
||||||
|
isDiffusionModel ? 'uiHelpers.nodeSelector.diffusionModel' : 'uiHelpers.nodeSelector.checkpoint',
|
||||||
|
{},
|
||||||
|
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
|
||||||
|
);
|
||||||
|
const successMessage = i18n.t(
|
||||||
|
'uiHelpers.workflow.modelUpdated',
|
||||||
|
{},
|
||||||
|
'Model updated in workflow'
|
||||||
|
);
|
||||||
|
const failureMessage = i18n.t(
|
||||||
|
'uiHelpers.workflow.modelFailed',
|
||||||
|
{},
|
||||||
|
'Failed to update model node'
|
||||||
|
);
|
||||||
|
const missingNodesMessage = i18n.t(
|
||||||
|
'uiHelpers.workflow.noMatchingNodes',
|
||||||
|
{},
|
||||||
|
'No compatible nodes available in the current workflow'
|
||||||
|
);
|
||||||
|
const missingTargetMessage = i18n.t(
|
||||||
|
'uiHelpers.workflow.noTargetNodeSelected',
|
||||||
|
{},
|
||||||
|
'No target node selected'
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendModelPathToWorkflow(modelPath, {
|
||||||
|
widgetName,
|
||||||
|
collectionType: MODEL_TYPES.CHECKPOINT,
|
||||||
|
actionTypeText,
|
||||||
|
successMessage,
|
||||||
|
failureMessage,
|
||||||
|
missingNodesMessage,
|
||||||
|
missingTargetMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mix in shared methods
|
// Mix in shared methods
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import { MODEL_TYPES } from '../api/apiConfig.js';
|
|||||||
|
|
||||||
class RecipeModal {
|
class RecipeModal {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.promptEditorState = {};
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.setupCopyButtons();
|
this.setupCopyButtons();
|
||||||
|
this.setupPromptEditors();
|
||||||
// Set up tooltip positioning handlers after DOM is ready
|
// Set up tooltip positioning handlers after DOM is ready
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
this.setupTooltipPositioning();
|
this.setupTooltipPositioning();
|
||||||
@@ -87,6 +89,7 @@ class RecipeModal {
|
|||||||
showRecipeDetails(recipe) {
|
showRecipeDetails(recipe) {
|
||||||
// Store the full recipe for editing
|
// Store the full recipe for editing
|
||||||
this.currentRecipe = recipe;
|
this.currentRecipe = recipe;
|
||||||
|
this.resetPromptEditors();
|
||||||
|
|
||||||
// Set modal title with edit icon
|
// Set modal title with edit icon
|
||||||
const modalTitle = document.getElementById('recipeModalTitle');
|
const modalTitle = document.getElementById('recipeModalTitle');
|
||||||
@@ -300,20 +303,19 @@ class RecipeModal {
|
|||||||
const promptElement = document.getElementById('recipePrompt');
|
const promptElement = document.getElementById('recipePrompt');
|
||||||
const negativePromptElement = document.getElementById('recipeNegativePrompt');
|
const negativePromptElement = document.getElementById('recipeNegativePrompt');
|
||||||
const otherParamsElement = document.getElementById('recipeOtherParams');
|
const otherParamsElement = document.getElementById('recipeOtherParams');
|
||||||
|
const promptInput = document.getElementById('recipePromptInput');
|
||||||
|
const negativePromptInput = document.getElementById('recipeNegativePromptInput');
|
||||||
|
|
||||||
if (recipe.gen_params) {
|
if (recipe.gen_params) {
|
||||||
// Set prompt
|
this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available');
|
||||||
if (promptElement && recipe.gen_params.prompt) {
|
this.renderPromptContent(negativePromptElement, recipe.gen_params.negative_prompt, 'No negative prompt information available');
|
||||||
promptElement.textContent = recipe.gen_params.prompt;
|
|
||||||
} else if (promptElement) {
|
if (promptInput) {
|
||||||
promptElement.textContent = 'No prompt information available';
|
promptInput.value = recipe.gen_params.prompt || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set negative prompt
|
if (negativePromptInput) {
|
||||||
if (negativePromptElement && recipe.gen_params.negative_prompt) {
|
negativePromptInput.value = recipe.gen_params.negative_prompt || '';
|
||||||
negativePromptElement.textContent = recipe.gen_params.negative_prompt;
|
|
||||||
} else if (negativePromptElement) {
|
|
||||||
negativePromptElement.textContent = 'No negative prompt information available';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set other parameters
|
// Set other parameters
|
||||||
@@ -343,8 +345,10 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No generation parameters available
|
// No generation parameters available
|
||||||
if (promptElement) promptElement.textContent = 'No prompt information available';
|
this.renderPromptContent(promptElement, '', 'No prompt information available');
|
||||||
if (negativePromptElement) promptElement.textContent = 'No negative prompt information available';
|
this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available');
|
||||||
|
if (promptInput) promptInput.value = '';
|
||||||
|
if (negativePromptInput) negativePromptInput.value = '';
|
||||||
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
|
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,16 +715,202 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupPromptEditors() {
|
||||||
|
const promptConfigs = [
|
||||||
|
{
|
||||||
|
editButtonId: 'editPromptBtn',
|
||||||
|
contentId: 'recipePrompt',
|
||||||
|
editorId: 'recipePromptEditor',
|
||||||
|
inputId: 'recipePromptInput',
|
||||||
|
field: 'prompt',
|
||||||
|
placeholder: 'No prompt information available',
|
||||||
|
successKey: 'toast.recipes.promptUpdated',
|
||||||
|
successFallback: 'Prompt updated successfully',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
editButtonId: 'editNegativePromptBtn',
|
||||||
|
contentId: 'recipeNegativePrompt',
|
||||||
|
editorId: 'recipeNegativePromptEditor',
|
||||||
|
inputId: 'recipeNegativePromptInput',
|
||||||
|
field: 'negative_prompt',
|
||||||
|
placeholder: 'No negative prompt information available',
|
||||||
|
successKey: 'toast.recipes.negativePromptUpdated',
|
||||||
|
successFallback: 'Negative prompt updated successfully',
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
promptConfigs.forEach((config) => {
|
||||||
|
const editButton = document.getElementById(config.editButtonId);
|
||||||
|
const input = document.getElementById(config.inputId);
|
||||||
|
|
||||||
|
if (editButton) {
|
||||||
|
editButton.addEventListener('click', () => this.showPromptEditor(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.cancelPromptEdit(config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.promptEditorState[config.field] = {
|
||||||
|
...(this.promptEditorState[config.field] || {}),
|
||||||
|
skipBlurSave: true,
|
||||||
|
};
|
||||||
|
this.savePromptEdit(config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener('blur', () => {
|
||||||
|
const promptState = this.promptEditorState[config.field] || {};
|
||||||
|
if (promptState.skipBlurSave) {
|
||||||
|
this.promptEditorState[config.field] = {
|
||||||
|
...promptState,
|
||||||
|
skipBlurSave: false,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.savePromptEdit(config);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPromptContent(element, value, placeholder) {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = value || '';
|
||||||
|
if (text) {
|
||||||
|
element.textContent = text;
|
||||||
|
element.classList.remove('is-placeholder');
|
||||||
|
} else {
|
||||||
|
element.textContent = placeholder;
|
||||||
|
element.classList.add('is-placeholder');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPromptEditors() {
|
||||||
|
this.hidePromptEditor({ contentId: 'recipePrompt', editorId: 'recipePromptEditor' });
|
||||||
|
this.hidePromptEditor({ contentId: 'recipeNegativePrompt', editorId: 'recipeNegativePromptEditor' });
|
||||||
|
}
|
||||||
|
|
||||||
|
showPromptEditor(config) {
|
||||||
|
const content = document.getElementById(config.contentId);
|
||||||
|
const editor = document.getElementById(config.editorId);
|
||||||
|
const input = document.getElementById(config.inputId);
|
||||||
|
|
||||||
|
if (!content || !editor || !input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValue = this.currentRecipe?.gen_params?.[config.field] || '';
|
||||||
|
input.value = currentValue;
|
||||||
|
this.promptEditorState[config.field] = {
|
||||||
|
initialValue: currentValue,
|
||||||
|
skipBlurSave: false,
|
||||||
|
isSaving: false,
|
||||||
|
};
|
||||||
|
content.classList.add('hide');
|
||||||
|
editor.classList.add('active');
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(input.value.length, input.value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async savePromptEdit(config) {
|
||||||
|
const content = document.getElementById(config.contentId);
|
||||||
|
const editor = document.getElementById(config.editorId);
|
||||||
|
const input = document.getElementById(config.inputId);
|
||||||
|
|
||||||
|
if (!content || !editor || !input || !this.currentRecipe) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptState = this.promptEditorState[config.field] || {};
|
||||||
|
if (promptState.isSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentGenParams = this.currentRecipe.gen_params || {};
|
||||||
|
const nextValue = input.value.trim() === '' ? '' : input.value;
|
||||||
|
const currentValue = currentGenParams[config.field] || '';
|
||||||
|
|
||||||
|
if (nextValue === currentValue) {
|
||||||
|
this.hidePromptEditor(config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextGenParams = {
|
||||||
|
...currentGenParams,
|
||||||
|
[config.field]: nextValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.promptEditorState[config.field] = {
|
||||||
|
...promptState,
|
||||||
|
isSaving: true,
|
||||||
|
};
|
||||||
|
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams });
|
||||||
|
this.currentRecipe.gen_params = nextGenParams;
|
||||||
|
this.renderPromptContent(content, nextValue, config.placeholder);
|
||||||
|
showToast(config.successKey, {}, 'success', config.successFallback);
|
||||||
|
} catch (error) {
|
||||||
|
this.renderPromptContent(content, currentValue, config.placeholder);
|
||||||
|
input.value = currentValue;
|
||||||
|
} finally {
|
||||||
|
this.hidePromptEditor(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPromptEdit(config) {
|
||||||
|
const input = document.getElementById(config.inputId);
|
||||||
|
if (input) {
|
||||||
|
const initialValue = this.promptEditorState[config.field]?.initialValue;
|
||||||
|
input.value = initialValue ?? (this.currentRecipe?.gen_params?.[config.field] || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hidePromptEditor(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
hidePromptEditor(config) {
|
||||||
|
const content = document.getElementById(config.contentId);
|
||||||
|
const editor = document.getElementById(config.editorId);
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
content.classList.remove('hide');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editor) {
|
||||||
|
editor.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
delete this.promptEditorState[config.field];
|
||||||
|
}
|
||||||
|
|
||||||
// Setup source URL handlers
|
// Setup source URL handlers
|
||||||
setupSourceUrlHandlers() {
|
setupSourceUrlHandlers() {
|
||||||
const sourceUrlContainer = document.querySelector('.source-url-container');
|
const sourceUrlContainer = document.querySelector('.source-url-container');
|
||||||
const sourceUrlEditor = document.querySelector('.source-url-editor');
|
const sourceUrlEditor = document.querySelector('.source-url-editor');
|
||||||
|
if (!sourceUrlContainer || !sourceUrlEditor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
|
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
|
||||||
const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn');
|
const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn');
|
||||||
const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn');
|
const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn');
|
||||||
const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn');
|
const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn');
|
||||||
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
|
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
|
||||||
|
|
||||||
|
if (!sourceUrlText || !sourceUrlEditBtn || !sourceUrlCancelBtn || !sourceUrlSaveBtn || !sourceUrlInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Show editor on edit button click
|
// Show editor on edit button click
|
||||||
sourceUrlEditBtn.addEventListener('click', () => {
|
sourceUrlEditBtn.addEventListener('click', () => {
|
||||||
sourceUrlContainer.classList.add('hide');
|
sourceUrlContainer.classList.add('hide');
|
||||||
@@ -782,14 +972,14 @@ class RecipeModal {
|
|||||||
|
|
||||||
if (copyPromptBtn) {
|
if (copyPromptBtn) {
|
||||||
copyPromptBtn.addEventListener('click', () => {
|
copyPromptBtn.addEventListener('click', () => {
|
||||||
const promptText = document.getElementById('recipePrompt').textContent;
|
const promptText = this.currentRecipe?.gen_params?.prompt || '';
|
||||||
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
|
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copyNegativePromptBtn) {
|
if (copyNegativePromptBtn) {
|
||||||
copyNegativePromptBtn.addEventListener('click', () => {
|
copyNegativePromptBtn.addEventListener('click', () => {
|
||||||
const negativePromptText = document.getElementById('recipeNegativePrompt').textContent;
|
const negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
|
||||||
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
|
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1225,14 +1415,14 @@ class RecipeModal {
|
|||||||
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
|
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
|
||||||
);
|
);
|
||||||
const successMessage = translate(
|
const successMessage = translate(
|
||||||
isDiffusionModel ? 'uiHelpers.workflow.diffusionModelUpdated' : 'uiHelpers.workflow.checkpointUpdated',
|
'uiHelpers.workflow.modelUpdated',
|
||||||
{},
|
{},
|
||||||
isDiffusionModel ? 'Diffusion model updated in workflow' : 'Checkpoint updated in workflow'
|
'Model updated in workflow'
|
||||||
);
|
);
|
||||||
const failureMessage = translate(
|
const failureMessage = translate(
|
||||||
isDiffusionModel ? 'uiHelpers.workflow.diffusionModelFailed' : 'uiHelpers.workflow.checkpointFailed',
|
'uiHelpers.workflow.modelFailed',
|
||||||
{},
|
{},
|
||||||
isDiffusionModel ? 'Failed to update diffusion model node' : 'Failed to update checkpoint node'
|
'Failed to update model node'
|
||||||
);
|
);
|
||||||
const missingNodesMessage = translate(
|
const missingNodesMessage = translate(
|
||||||
'uiHelpers.workflow.noMatchingNodes',
|
'uiHelpers.workflow.noMatchingNodes',
|
||||||
|
|||||||
@@ -185,14 +185,14 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
|
|||||||
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
|
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
|
||||||
);
|
);
|
||||||
const successMessage = translate(
|
const successMessage = translate(
|
||||||
isDiffusionModel ? 'uiHelpers.workflow.diffusionModelUpdated' : 'uiHelpers.workflow.checkpointUpdated',
|
'uiHelpers.workflow.modelUpdated',
|
||||||
{},
|
{},
|
||||||
isDiffusionModel ? 'Diffusion model updated in workflow' : 'Checkpoint updated in workflow'
|
'Model updated in workflow'
|
||||||
);
|
);
|
||||||
const failureMessage = translate(
|
const failureMessage = translate(
|
||||||
isDiffusionModel ? 'uiHelpers.workflow.diffusionModelFailed' : 'uiHelpers.workflow.checkpointFailed',
|
'uiHelpers.workflow.modelFailed',
|
||||||
{},
|
{},
|
||||||
isDiffusionModel ? 'Failed to update diffusion model node' : 'Failed to update checkpoint node'
|
'Failed to update model node'
|
||||||
);
|
);
|
||||||
const missingNodesMessage = translate(
|
const missingNodesMessage = translate(
|
||||||
'uiHelpers.workflow.noMatchingNodes',
|
'uiHelpers.workflow.noMatchingNodes',
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { renderCompactTags, setupTagTooltip, formatFileSize, escapeAttribute, es
|
|||||||
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
||||||
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||||
import { initVersionsTab } from './ModelVersionsTab.js';
|
import { initVersionsTab } from './ModelVersionsTab.js';
|
||||||
import { loadRecipesForLora } from './RecipeTab.js';
|
import { loadRecipesForModel } from './RecipeTab.js';
|
||||||
import { translate } from '../../utils/i18nHelpers.js';
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
|
|
||||||
@@ -355,7 +355,9 @@ export async function showModelModal(model, modelType) {
|
|||||||
${versionsTabBadge}
|
${versionsTabBadge}
|
||||||
</button>`.trim();
|
</button>`.trim();
|
||||||
|
|
||||||
const tabsContent = modelType === 'loras' ?
|
const supportsRecipesTab = modelType === 'loras' || modelType === 'checkpoints';
|
||||||
|
|
||||||
|
const tabsContent = supportsRecipesTab ?
|
||||||
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
||||||
<button class="tab-btn" data-tab="description">${descriptionText}</button>
|
<button class="tab-btn" data-tab="description">${descriptionText}</button>
|
||||||
${versionsTabButton}
|
${versionsTabButton}
|
||||||
@@ -385,7 +387,7 @@ export async function showModelModal(model, modelType) {
|
|||||||
</button>
|
</button>
|
||||||
</div>`.trim();
|
</div>`.trim();
|
||||||
|
|
||||||
const tabPanesContent = modelType === 'loras' ?
|
const tabPanesContent = supportsRecipesTab ?
|
||||||
`<div id="showcase-tab" class="tab-pane active">
|
`<div id="showcase-tab" class="tab-pane active">
|
||||||
<div class="example-images-loading">
|
<div class="example-images-loading">
|
||||||
<i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText}
|
<i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText}
|
||||||
@@ -664,14 +666,23 @@ export async function showModelModal(model, modelType) {
|
|||||||
setupNavigationShortcuts(modelType);
|
setupNavigationShortcuts(modelType);
|
||||||
updateNavigationControls();
|
updateNavigationControls();
|
||||||
|
|
||||||
// LoRA specific setup
|
// Model-specific setup
|
||||||
if (modelType === 'loras' || modelType === 'embeddings') {
|
if (modelType === 'loras' || modelType === 'embeddings') {
|
||||||
setupTriggerWordsEditMode();
|
setupTriggerWordsEditMode();
|
||||||
|
}
|
||||||
|
|
||||||
if (modelType == 'loras') {
|
if (modelType === 'loras') {
|
||||||
// Load recipes for this LoRA
|
loadRecipesForModel({
|
||||||
loadRecipesForLora(modelWithFullData.model_name, modelWithFullData.sha256);
|
modelKind: 'lora',
|
||||||
}
|
displayName: modelWithFullData.model_name,
|
||||||
|
sha256: modelWithFullData.sha256,
|
||||||
|
});
|
||||||
|
} else if (modelType === 'checkpoints') {
|
||||||
|
loadRecipesForModel({
|
||||||
|
modelKind: 'checkpoint',
|
||||||
|
displayName: modelWithFullData.model_name,
|
||||||
|
sha256: modelWithFullData.sha256,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load example images asynchronously - merge regular and custom images
|
// Load example images asynchronously - merge regular and custom images
|
||||||
@@ -1077,14 +1088,14 @@ async function handleSendToWorkflow(target, modelType) {
|
|||||||
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
|
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
|
||||||
);
|
);
|
||||||
const successMessage = translate(
|
const successMessage = translate(
|
||||||
isDiffusionModel ? 'uiHelpers.workflow.diffusionModelUpdated' : 'uiHelpers.workflow.checkpointUpdated',
|
'uiHelpers.workflow.modelUpdated',
|
||||||
{},
|
{},
|
||||||
isDiffusionModel ? 'Diffusion model updated in workflow' : 'Checkpoint updated in workflow'
|
'Model updated in workflow'
|
||||||
);
|
);
|
||||||
const failureMessage = translate(
|
const failureMessage = translate(
|
||||||
isDiffusionModel ? 'uiHelpers.workflow.diffusionModelFailed' : 'uiHelpers.workflow.checkpointFailed',
|
'uiHelpers.workflow.modelFailed',
|
||||||
{},
|
{},
|
||||||
isDiffusionModel ? 'Failed to update diffusion model node' : 'Failed to update checkpoint node'
|
'Failed to update model node'
|
||||||
);
|
);
|
||||||
const missingNodesMessage = translate(
|
const missingNodesMessage = translate(
|
||||||
'uiHelpers.workflow.noMatchingNodes',
|
'uiHelpers.workflow.noMatchingNodes',
|
||||||
|
|||||||
@@ -1,38 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* RecipeTab - Handles the recipes tab in model modals (LoRA specific functionality)
|
* RecipeTab - Handles the recipes tab in model modals.
|
||||||
* Moved to shared directory for consistency
|
|
||||||
*/
|
*/
|
||||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads recipes that use the specified Lora and renders them in the tab
|
* Loads recipes that use the specified model and renders them in the tab.
|
||||||
* @param {string} loraName - The display name of the Lora
|
* @param {Object} options
|
||||||
* @param {string} sha256 - The SHA256 hash of the Lora
|
* @param {'lora'|'checkpoint'} options.modelKind - Model kind for copy and endpoint selection
|
||||||
|
* @param {string} options.displayName - The display name of the model
|
||||||
|
* @param {string} options.sha256 - The SHA256 hash of the model
|
||||||
*/
|
*/
|
||||||
export function loadRecipesForLora(loraName, sha256) {
|
export function loadRecipesForModel({ modelKind, displayName, sha256 }) {
|
||||||
const recipeTab = document.getElementById('recipes-tab');
|
const recipeTab = document.getElementById('recipes-tab');
|
||||||
if (!recipeTab) return;
|
if (!recipeTab) return;
|
||||||
|
|
||||||
|
const normalizedHash = sha256?.toLowerCase?.() || '';
|
||||||
|
const modelLabel = getModelLabel(modelKind);
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
recipeTab.innerHTML = `
|
recipeTab.innerHTML = `
|
||||||
<div class="recipes-loading">
|
<div class="recipes-loading">
|
||||||
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
|
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Fetch recipes that use this Lora by hash
|
// Fetch recipes that use this model by hash
|
||||||
fetch(`/api/lm/recipes/for-lora?hash=${encodeURIComponent(sha256.toLowerCase())}`)
|
fetch(`${getRecipesEndpoint(modelKind)}?hash=${encodeURIComponent(normalizedHash)}`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
throw new Error(data.error || 'Failed to load recipes');
|
throw new Error(data.error || 'Failed to load recipes');
|
||||||
}
|
}
|
||||||
|
|
||||||
renderRecipes(recipeTab, data.recipes, loraName, sha256);
|
renderRecipes(recipeTab, data.recipes, {
|
||||||
|
modelKind,
|
||||||
|
displayName,
|
||||||
|
modelHash: normalizedHash,
|
||||||
|
modelLabel,
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error loading recipes for Lora:', error);
|
console.error(`Error loading recipes for ${modelLabel}:`, error);
|
||||||
recipeTab.innerHTML = `
|
recipeTab.innerHTML = `
|
||||||
<div class="recipes-error">
|
<div class="recipes-error">
|
||||||
<i class="fas fa-exclamation-circle"></i>
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
@@ -46,18 +55,24 @@ export function loadRecipesForLora(loraName, sha256) {
|
|||||||
* Renders the recipe cards in the tab
|
* Renders the recipe cards in the tab
|
||||||
* @param {HTMLElement} tabElement - The tab element to render into
|
* @param {HTMLElement} tabElement - The tab element to render into
|
||||||
* @param {Array} recipes - Array of recipe objects
|
* @param {Array} recipes - Array of recipe objects
|
||||||
* @param {string} loraName - The display name of the Lora
|
* @param {Object} options - Render options
|
||||||
* @param {string} loraHash - The hash of the Lora
|
|
||||||
*/
|
*/
|
||||||
function renderRecipes(tabElement, recipes, loraName, loraHash) {
|
function renderRecipes(tabElement, recipes, options) {
|
||||||
|
const {
|
||||||
|
modelKind,
|
||||||
|
displayName,
|
||||||
|
modelHash,
|
||||||
|
modelLabel,
|
||||||
|
} = options;
|
||||||
|
|
||||||
if (!recipes || recipes.length === 0) {
|
if (!recipes || recipes.length === 0) {
|
||||||
tabElement.innerHTML = `
|
tabElement.innerHTML = `
|
||||||
<div class="recipes-empty">
|
<div class="recipes-empty">
|
||||||
<i class="fas fa-book-open"></i>
|
<i class="fas fa-book-open"></i>
|
||||||
<p>No recipes found that use this Lora.</p>
|
<p>No recipes found that use this ${modelLabel}.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,13 +88,13 @@ function renderRecipes(tabElement, recipes, loraName, loraHash) {
|
|||||||
headerText.appendChild(eyebrow);
|
headerText.appendChild(eyebrow);
|
||||||
|
|
||||||
const title = document.createElement('h3');
|
const title = document.createElement('h3');
|
||||||
title.textContent = `${recipes.length} recipe${recipes.length > 1 ? 's' : ''} using this Lora`;
|
title.textContent = `${recipes.length} recipe${recipes.length > 1 ? 's' : ''} using this ${modelLabel}`;
|
||||||
headerText.appendChild(title);
|
headerText.appendChild(title);
|
||||||
|
|
||||||
const description = document.createElement('p');
|
const description = document.createElement('p');
|
||||||
description.className = 'recipes-header__description';
|
description.className = 'recipes-header__description';
|
||||||
description.textContent = loraName ?
|
description.textContent = displayName ?
|
||||||
`Discover workflows crafted for ${loraName}.` :
|
`Discover workflows crafted for ${displayName}.` :
|
||||||
'Discover workflows crafted for this model.';
|
'Discover workflows crafted for this model.';
|
||||||
headerText.appendChild(description);
|
headerText.appendChild(description);
|
||||||
|
|
||||||
@@ -101,7 +116,11 @@ function renderRecipes(tabElement, recipes, loraName, loraHash) {
|
|||||||
headerElement.appendChild(viewAllButton);
|
headerElement.appendChild(viewAllButton);
|
||||||
|
|
||||||
viewAllButton.addEventListener('click', () => {
|
viewAllButton.addEventListener('click', () => {
|
||||||
navigateToRecipesPage(loraName, loraHash);
|
navigateToRecipesPage({
|
||||||
|
modelKind,
|
||||||
|
displayName,
|
||||||
|
modelHash,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const cardGrid = document.createElement('div');
|
const cardGrid = document.createElement('div');
|
||||||
@@ -280,26 +299,32 @@ function copyRecipeSyntax(recipeId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigates to the recipes page with filter for the current Lora
|
* Navigates to the recipes page with filter for the current model
|
||||||
* @param {string} loraName - The Lora display name to filter by
|
* @param {Object} options - Navigation options
|
||||||
* @param {string} loraHash - The hash of the Lora to filter by
|
|
||||||
* @param {boolean} createNew - Whether to open the create recipe dialog
|
|
||||||
*/
|
*/
|
||||||
function navigateToRecipesPage(loraName, loraHash) {
|
function navigateToRecipesPage({ modelKind, displayName, modelHash }) {
|
||||||
// Close the current modal
|
// Close the current modal
|
||||||
if (window.modalManager) {
|
if (window.modalManager) {
|
||||||
modalManager.closeModal('modelModal');
|
modalManager.closeModal('modelModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear any previous filters first
|
// Clear any previous filters first
|
||||||
removeSessionItem('lora_to_recipe_filterLoraName');
|
removeSessionItem('lora_to_recipe_filterLoraName');
|
||||||
removeSessionItem('lora_to_recipe_filterLoraHash');
|
removeSessionItem('lora_to_recipe_filterLoraHash');
|
||||||
|
removeSessionItem('checkpoint_to_recipe_filterCheckpointName');
|
||||||
|
removeSessionItem('checkpoint_to_recipe_filterCheckpointHash');
|
||||||
removeSessionItem('viewRecipeId');
|
removeSessionItem('viewRecipeId');
|
||||||
|
|
||||||
// Store the LoRA name and hash filter in sessionStorage
|
if (modelKind === 'checkpoint') {
|
||||||
setSessionItem('lora_to_recipe_filterLoraName', loraName);
|
// Store the checkpoint name and hash filter in sessionStorage
|
||||||
setSessionItem('lora_to_recipe_filterLoraHash', loraHash);
|
setSessionItem('checkpoint_to_recipe_filterCheckpointName', displayName);
|
||||||
|
setSessionItem('checkpoint_to_recipe_filterCheckpointHash', modelHash);
|
||||||
|
} else {
|
||||||
|
// Store the LoRA name and hash filter in sessionStorage
|
||||||
|
setSessionItem('lora_to_recipe_filterLoraName', displayName);
|
||||||
|
setSessionItem('lora_to_recipe_filterLoraHash', modelHash);
|
||||||
|
}
|
||||||
|
|
||||||
// Directly navigate to recipes page
|
// Directly navigate to recipes page
|
||||||
window.location.href = '/loras/recipes';
|
window.location.href = '/loras/recipes';
|
||||||
}
|
}
|
||||||
@@ -321,7 +346,18 @@ function navigateToRecipeDetails(recipeId) {
|
|||||||
|
|
||||||
// Store the recipe ID in sessionStorage to load on recipes page
|
// Store the recipe ID in sessionStorage to load on recipes page
|
||||||
setSessionItem('viewRecipeId', recipeId);
|
setSessionItem('viewRecipeId', recipeId);
|
||||||
|
|
||||||
// Directly navigate to recipes page
|
// Directly navigate to recipes page
|
||||||
window.location.href = '/loras/recipes';
|
window.location.href = '/loras/recipes';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRecipesEndpoint(modelKind) {
|
||||||
|
if (modelKind === 'checkpoint') {
|
||||||
|
return '/api/lm/recipes/for-checkpoint';
|
||||||
|
}
|
||||||
|
return '/api/lm/recipes/for-lora';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelLabel(modelKind) {
|
||||||
|
return modelKind === 'checkpoint' ? 'checkpoint' : 'LoRA';
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ class RecipeManager {
|
|||||||
active: false,
|
active: false,
|
||||||
loraName: null,
|
loraName: null,
|
||||||
loraHash: null,
|
loraHash: null,
|
||||||
|
checkpointName: null,
|
||||||
|
checkpointHash: null,
|
||||||
recipeId: null
|
recipeId: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -127,16 +129,20 @@ class RecipeManager {
|
|||||||
// Check for Lora filter
|
// Check for Lora filter
|
||||||
const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName');
|
const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName');
|
||||||
const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash');
|
const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash');
|
||||||
|
const filterCheckpointName = getSessionItem('checkpoint_to_recipe_filterCheckpointName');
|
||||||
|
const filterCheckpointHash = getSessionItem('checkpoint_to_recipe_filterCheckpointHash');
|
||||||
|
|
||||||
// Check for specific recipe ID
|
// Check for specific recipe ID
|
||||||
const viewRecipeId = getSessionItem('viewRecipeId');
|
const viewRecipeId = getSessionItem('viewRecipeId');
|
||||||
|
|
||||||
// Set custom filter if any parameter is present
|
// Set custom filter if any parameter is present
|
||||||
if (filterLoraName || filterLoraHash || viewRecipeId) {
|
if (filterLoraName || filterLoraHash || filterCheckpointName || filterCheckpointHash || viewRecipeId) {
|
||||||
this.pageState.customFilter = {
|
this.pageState.customFilter = {
|
||||||
active: true,
|
active: true,
|
||||||
loraName: filterLoraName,
|
loraName: filterLoraName,
|
||||||
loraHash: filterLoraHash,
|
loraHash: filterLoraHash,
|
||||||
|
checkpointName: filterCheckpointName,
|
||||||
|
checkpointHash: filterCheckpointHash,
|
||||||
recipeId: viewRecipeId
|
recipeId: viewRecipeId
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,6 +170,13 @@ class RecipeManager {
|
|||||||
loraName;
|
loraName;
|
||||||
|
|
||||||
filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`;
|
filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`;
|
||||||
|
} else if (this.pageState.customFilter.checkpointName) {
|
||||||
|
const checkpointName = this.pageState.customFilter.checkpointName;
|
||||||
|
const displayName = checkpointName.length > 25 ?
|
||||||
|
checkpointName.substring(0, 22) + '...' :
|
||||||
|
checkpointName;
|
||||||
|
|
||||||
|
filterText = `<span>Recipes using checkpoint: <span class="lora-name">${displayName}</span></span>`;
|
||||||
} else {
|
} else {
|
||||||
filterText = 'Filtered recipes';
|
filterText = 'Filtered recipes';
|
||||||
}
|
}
|
||||||
@@ -173,6 +186,10 @@ class RecipeManager {
|
|||||||
// Add title attribute to show the lora name as a tooltip
|
// Add title attribute to show the lora name as a tooltip
|
||||||
if (this.pageState.customFilter.loraName) {
|
if (this.pageState.customFilter.loraName) {
|
||||||
textElement.setAttribute('title', this.pageState.customFilter.loraName);
|
textElement.setAttribute('title', this.pageState.customFilter.loraName);
|
||||||
|
} else if (this.pageState.customFilter.checkpointName) {
|
||||||
|
textElement.setAttribute('title', this.pageState.customFilter.checkpointName);
|
||||||
|
} else {
|
||||||
|
textElement.removeAttribute('title');
|
||||||
}
|
}
|
||||||
indicator.classList.remove('hidden');
|
indicator.classList.remove('hidden');
|
||||||
|
|
||||||
@@ -199,6 +216,8 @@ class RecipeManager {
|
|||||||
active: false,
|
active: false,
|
||||||
loraName: null,
|
loraName: null,
|
||||||
loraHash: null,
|
loraHash: null,
|
||||||
|
checkpointName: null,
|
||||||
|
checkpointHash: null,
|
||||||
recipeId: null
|
recipeId: null
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -211,6 +230,8 @@ class RecipeManager {
|
|||||||
// Clear any session storage items
|
// Clear any session storage items
|
||||||
removeSessionItem('lora_to_recipe_filterLoraName');
|
removeSessionItem('lora_to_recipe_filterLoraName');
|
||||||
removeSessionItem('lora_to_recipe_filterLoraHash');
|
removeSessionItem('lora_to_recipe_filterLoraHash');
|
||||||
|
removeSessionItem('checkpoint_to_recipe_filterCheckpointName');
|
||||||
|
removeSessionItem('checkpoint_to_recipe_filterCheckpointHash');
|
||||||
removeSessionItem('viewRecipeId');
|
removeSessionItem('viewRecipeId');
|
||||||
|
|
||||||
// Reset and refresh the virtual scroller
|
// Reset and refresh the virtual scroller
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> {{ t('loras.contextMenu.refreshMetadata') }}</div>
|
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> {{ t('loras.contextMenu.refreshMetadata') }}</div>
|
||||||
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> {{ t('loras.contextMenu.relinkCivitai') }}</div>
|
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> {{ t('loras.contextMenu.relinkCivitai') }}</div>
|
||||||
<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="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
|
||||||
|
<div class="context-menu-item" data-action="sendworkflow"><i class="fas fa-paper-plane"></i> {{ t('checkpoints.contextMenu.sendToWorkflow') }}</div>
|
||||||
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
|
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
|
||||||
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div>
|
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div>
|
||||||
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>
|
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>
|
||||||
|
|||||||
@@ -29,22 +29,52 @@
|
|||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
<div class="param-header">
|
<div class="param-header">
|
||||||
<label>Prompt</label>
|
<label>Prompt</label>
|
||||||
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt">
|
<div class="param-actions">
|
||||||
<i class="fas fa-copy"></i>
|
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt">
|
||||||
</button>
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
<button class="edit-btn" id="editPromptBtn" title="Edit Prompt">
|
||||||
|
<i class="fas fa-pencil-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="param-content" id="recipePrompt"></div>
|
<div class="param-content" id="recipePrompt"></div>
|
||||||
|
<div class="param-editor" id="recipePromptEditor">
|
||||||
|
<textarea
|
||||||
|
class="param-textarea"
|
||||||
|
id="recipePromptInput"
|
||||||
|
placeholder="Enter prompt"
|
||||||
|
></textarea>
|
||||||
|
<div class="param-editor-hint">
|
||||||
|
{{ t('toast.recipes.promptEditorHint') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Negative Prompt -->
|
<!-- Negative Prompt -->
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
<div class="param-header">
|
<div class="param-header">
|
||||||
<label>Negative Prompt</label>
|
<label>Negative Prompt</label>
|
||||||
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt">
|
<div class="param-actions">
|
||||||
<i class="fas fa-copy"></i>
|
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt">
|
||||||
</button>
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
<button class="edit-btn" id="editNegativePromptBtn" title="Edit Negative Prompt">
|
||||||
|
<i class="fas fa-pencil-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="param-content" id="recipeNegativePrompt"></div>
|
<div class="param-content" id="recipeNegativePrompt"></div>
|
||||||
|
<div class="param-editor" id="recipeNegativePromptEditor">
|
||||||
|
<textarea
|
||||||
|
class="param-textarea"
|
||||||
|
id="recipeNegativePromptInput"
|
||||||
|
placeholder="Enter negative prompt"
|
||||||
|
></textarea>
|
||||||
|
<div class="param-editor-hint">
|
||||||
|
{{ t('toast.recipes.promptEditorHint') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Other Parameters -->
|
<!-- Other Parameters -->
|
||||||
|
|||||||
@@ -245,16 +245,28 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
<div class="param-header">
|
<div class="param-header">
|
||||||
<label>Prompt</label>
|
<label>Prompt</label>
|
||||||
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"><i class="fas fa-copy"></i></button>
|
<div class="param-actions">
|
||||||
|
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"><i class="fas fa-copy"></i></button>
|
||||||
|
<button class="edit-btn" id="editPromptBtn" title="Edit Prompt"><i class="fas fa-pencil-alt"></i></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="param-content" id="recipePrompt"></div>
|
<div class="param-content" id="recipePrompt"></div>
|
||||||
|
<div class="param-editor" id="recipePromptEditor">
|
||||||
|
<textarea class="param-textarea" id="recipePromptInput"></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
<div class="param-header">
|
<div class="param-header">
|
||||||
<label>Negative Prompt</label>
|
<label>Negative Prompt</label>
|
||||||
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"><i class="fas fa-copy"></i></button>
|
<div class="param-actions">
|
||||||
|
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"><i class="fas fa-copy"></i></button>
|
||||||
|
<button class="edit-btn" id="editNegativePromptBtn" title="Edit Negative Prompt"><i class="fas fa-pencil-alt"></i></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="param-content" id="recipeNegativePrompt"></div>
|
<div class="param-content" id="recipeNegativePrompt"></div>
|
||||||
|
<div class="param-editor" id="recipeNegativePromptEditor">
|
||||||
|
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -324,6 +336,208 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
expect(recipeModal.currentRecipe.title).toBe('Updated Title');
|
expect(recipeModal.currentRecipe.title).toBe('Updated Title');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('saves prompt edits on Enter while preserving Shift+Enter for new lines', async () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="recipeModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<header class="recipe-modal-header">
|
||||||
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-tags-container">
|
||||||
|
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||||
|
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||||
|
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="recipe-top-section">
|
||||||
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
|
</div>
|
||||||
|
<div class="info-section recipe-gen-params">
|
||||||
|
<div class="gen-params-container">
|
||||||
|
<div class="param-group info-item">
|
||||||
|
<div class="param-header">
|
||||||
|
<label>Prompt</label>
|
||||||
|
<div class="param-actions">
|
||||||
|
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"><i class="fas fa-copy"></i></button>
|
||||||
|
<button class="edit-btn" id="editPromptBtn" title="Edit Prompt"><i class="fas fa-pencil-alt"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="param-content" id="recipePrompt"></div>
|
||||||
|
<div class="param-editor" id="recipePromptEditor">
|
||||||
|
<textarea class="param-textarea" id="recipePromptInput"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="param-group info-item">
|
||||||
|
<div class="param-header">
|
||||||
|
<label>Negative Prompt</label>
|
||||||
|
<div class="param-actions">
|
||||||
|
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"><i class="fas fa-copy"></i></button>
|
||||||
|
<button class="edit-btn" id="editNegativePromptBtn" title="Edit Negative Prompt"><i class="fas fa-pencil-alt"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="param-content" id="recipeNegativePrompt"></div>
|
||||||
|
<div class="param-editor" id="recipeNegativePromptEditor">
|
||||||
|
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-section recipe-bottom-section">
|
||||||
|
<div class="recipe-section-header">
|
||||||
|
<h3>Resources</h3>
|
||||||
|
<div class="recipe-section-actions">
|
||||||
|
<span id="recipeLorasCount"><i class="fas fa-layer-group"></i> 0 LoRAs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
|
||||||
|
const recipeModal = new RecipeModal();
|
||||||
|
|
||||||
|
recipeModal.showRecipeDetails({
|
||||||
|
id: 'recipe-2',
|
||||||
|
file_path: '/recipes/prompt.json',
|
||||||
|
title: 'Prompt Recipe',
|
||||||
|
tags: [],
|
||||||
|
file_url: '',
|
||||||
|
preview_url: '',
|
||||||
|
source_path: '',
|
||||||
|
gen_params: {
|
||||||
|
prompt: 'old prompt',
|
||||||
|
negative_prompt: 'keep negative',
|
||||||
|
steps: 30,
|
||||||
|
cfg_scale: 7,
|
||||||
|
},
|
||||||
|
loras: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('editPromptBtn').click();
|
||||||
|
const textarea = document.getElementById('recipePromptInput');
|
||||||
|
textarea.value = 'new prompt text';
|
||||||
|
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true }));
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
expect(updateRecipeMetadataMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
await updateRecipeMetadataMock.mock.results[0].value;
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
expect(updateRecipeMetadataMock).toHaveBeenCalledWith('/recipes/prompt.json', {
|
||||||
|
gen_params: {
|
||||||
|
prompt: 'new prompt text',
|
||||||
|
negative_prompt: 'keep negative',
|
||||||
|
steps: 30,
|
||||||
|
cfg_scale: 7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(document.getElementById('recipePrompt').textContent).toBe('new prompt text');
|
||||||
|
expect(recipeModal.currentRecipe.gen_params.prompt).toBe('new prompt text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels negative prompt edits on Escape without saving', async () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="recipeModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<header class="recipe-modal-header">
|
||||||
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-tags-container">
|
||||||
|
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||||
|
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||||
|
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="recipe-top-section">
|
||||||
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
|
</div>
|
||||||
|
<div class="info-section recipe-gen-params">
|
||||||
|
<div class="gen-params-container">
|
||||||
|
<div class="param-group info-item">
|
||||||
|
<div class="param-header">
|
||||||
|
<label>Prompt</label>
|
||||||
|
<div class="param-actions">
|
||||||
|
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"><i class="fas fa-copy"></i></button>
|
||||||
|
<button class="edit-btn" id="editPromptBtn" title="Edit Prompt"><i class="fas fa-pencil-alt"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="param-content" id="recipePrompt"></div>
|
||||||
|
<div class="param-editor" id="recipePromptEditor">
|
||||||
|
<textarea class="param-textarea" id="recipePromptInput"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="param-group info-item">
|
||||||
|
<div class="param-header">
|
||||||
|
<label>Negative Prompt</label>
|
||||||
|
<div class="param-actions">
|
||||||
|
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"><i class="fas fa-copy"></i></button>
|
||||||
|
<button class="edit-btn" id="editNegativePromptBtn" title="Edit Negative Prompt"><i class="fas fa-pencil-alt"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="param-content" id="recipeNegativePrompt"></div>
|
||||||
|
<div class="param-editor" id="recipeNegativePromptEditor">
|
||||||
|
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-section recipe-bottom-section">
|
||||||
|
<div class="recipe-section-header">
|
||||||
|
<h3>Resources</h3>
|
||||||
|
<div class="recipe-section-actions">
|
||||||
|
<span id="recipeLorasCount"><i class="fas fa-layer-group"></i> 0 LoRAs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
|
||||||
|
const recipeModal = new RecipeModal();
|
||||||
|
|
||||||
|
recipeModal.showRecipeDetails({
|
||||||
|
id: 'recipe-3',
|
||||||
|
file_path: '/recipes/negative.json',
|
||||||
|
title: 'Negative Recipe',
|
||||||
|
tags: [],
|
||||||
|
file_url: '',
|
||||||
|
preview_url: '',
|
||||||
|
source_path: '',
|
||||||
|
gen_params: {
|
||||||
|
prompt: '',
|
||||||
|
negative_prompt: 'existing negative',
|
||||||
|
steps: 20,
|
||||||
|
},
|
||||||
|
loras: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('editNegativePromptBtn').click();
|
||||||
|
const textarea = document.getElementById('recipeNegativePromptInput');
|
||||||
|
textarea.value = 'changed negative';
|
||||||
|
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
|
||||||
|
expect(updateRecipeMetadataMock).not.toHaveBeenCalled();
|
||||||
|
expect(modalManagerMock.closeModal).not.toHaveBeenCalled();
|
||||||
|
expect(document.getElementById('recipeNegativePrompt').textContent).toBe('existing negative');
|
||||||
|
expect(document.getElementById('recipeNegativePromptEditor').classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('processes global context menu actions for downloads and cleanup', async () => {
|
it('processes global context menu actions for downloads and cleanup', async () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div id="globalContextMenu" class="context-menu">
|
<div id="globalContextMenu" class="context-menu">
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ vi.mock(MODEL_VERSIONS_MODULE, () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock(RECIPE_TAB_MODULE, () => ({
|
vi.mock(RECIPE_TAB_MODULE, () => ({
|
||||||
loadRecipesForLora: vi.fn(),
|
loadRecipesForModel: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock(I18N_HELPERS_MODULE, () => ({
|
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||||
@@ -103,11 +103,14 @@ vi.mock(API_FACTORY, () => ({
|
|||||||
|
|
||||||
describe('Model metadata interactions keep file path in sync', () => {
|
describe('Model metadata interactions keep file path in sync', () => {
|
||||||
let getModelApiClient;
|
let getModelApiClient;
|
||||||
|
let loadRecipesForModel;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
({ getModelApiClient } = await import(API_FACTORY));
|
({ getModelApiClient } = await import(API_FACTORY));
|
||||||
|
({ loadRecipesForModel } = await import(RECIPE_TAB_MODULE));
|
||||||
getModelApiClient.mockReset();
|
getModelApiClient.mockReset();
|
||||||
|
loadRecipesForModel.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -206,4 +209,33 @@ describe('Model metadata interactions keep file path in sync', () => {
|
|||||||
expect(saveModelMetadata).toHaveBeenCalledWith('models/Qwen.testing.safetensors', { notes: 'Updated notes' });
|
expect(saveModelMetadata).toHaveBeenCalledWith('models/Qwen.testing.safetensors', { notes: 'Updated notes' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows recipes tab for checkpoint modals and loads linked recipes by hash', async () => {
|
||||||
|
const fetchModelMetadata = vi.fn().mockResolvedValue(null);
|
||||||
|
|
||||||
|
getModelApiClient.mockReturnValue({
|
||||||
|
fetchModelMetadata,
|
||||||
|
saveModelMetadata: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showModelModal } = await import(MODAL_MODULE);
|
||||||
|
|
||||||
|
await showModelModal(
|
||||||
|
{
|
||||||
|
model_name: 'Flux Base',
|
||||||
|
file_path: 'models/checkpoints/flux-base.safetensors',
|
||||||
|
file_name: 'flux-base.safetensors',
|
||||||
|
sha256: 'ABC123',
|
||||||
|
civitai: {},
|
||||||
|
},
|
||||||
|
'checkpoints',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.querySelector('.tab-btn[data-tab="recipes"]')).not.toBeNull();
|
||||||
|
expect(loadRecipesForModel).toHaveBeenCalledWith({
|
||||||
|
modelKind: 'checkpoint',
|
||||||
|
displayName: 'Flux Base',
|
||||||
|
sha256: 'ABC123',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ vi.mock(MODEL_VERSIONS_MODULE, () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock(RECIPE_TAB_MODULE, () => ({
|
vi.mock(RECIPE_TAB_MODULE, () => ({
|
||||||
loadRecipesForLora: vi.fn(),
|
loadRecipesForModel: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock(I18N_HELPERS_MODULE, () => ({
|
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const initializePageFeaturesMock = vi.fn();
|
|||||||
const getCurrentPageStateMock = vi.fn();
|
const getCurrentPageStateMock = vi.fn();
|
||||||
const getSessionItemMock = vi.fn();
|
const getSessionItemMock = vi.fn();
|
||||||
const removeSessionItemMock = vi.fn();
|
const removeSessionItemMock = vi.fn();
|
||||||
|
const getStorageItemMock = vi.fn();
|
||||||
const RecipeContextMenuMock = vi.fn();
|
const RecipeContextMenuMock = vi.fn();
|
||||||
const refreshVirtualScrollMock = vi.fn();
|
const refreshVirtualScrollMock = vi.fn();
|
||||||
const refreshRecipesMock = vi.fn();
|
const refreshRecipesMock = vi.fn();
|
||||||
@@ -51,6 +52,7 @@ vi.mock('../../../static/js/state/index.js', () => ({
|
|||||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||||
getSessionItem: getSessionItemMock,
|
getSessionItem: getSessionItemMock,
|
||||||
removeSessionItem: removeSessionItemMock,
|
removeSessionItem: removeSessionItemMock,
|
||||||
|
getStorageItem: getStorageItemMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
|
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
|
||||||
@@ -117,11 +119,14 @@ describe('RecipeManager', () => {
|
|||||||
const map = {
|
const map = {
|
||||||
lora_to_recipe_filterLoraName: 'Flux Dream',
|
lora_to_recipe_filterLoraName: 'Flux Dream',
|
||||||
lora_to_recipe_filterLoraHash: 'abc123',
|
lora_to_recipe_filterLoraHash: 'abc123',
|
||||||
|
checkpoint_to_recipe_filterCheckpointName: null,
|
||||||
|
checkpoint_to_recipe_filterCheckpointHash: null,
|
||||||
viewRecipeId: '42',
|
viewRecipeId: '42',
|
||||||
};
|
};
|
||||||
return map[key] ?? null;
|
return map[key] ?? null;
|
||||||
});
|
});
|
||||||
removeSessionItemMock.mockImplementation(() => { });
|
removeSessionItemMock.mockImplementation(() => { });
|
||||||
|
getStorageItemMock.mockImplementation((_, defaultValue = null) => defaultValue);
|
||||||
|
|
||||||
renderRecipesPage();
|
renderRecipesPage();
|
||||||
|
|
||||||
@@ -166,6 +171,8 @@ describe('RecipeManager', () => {
|
|||||||
active: true,
|
active: true,
|
||||||
loraName: 'Flux Dream',
|
loraName: 'Flux Dream',
|
||||||
loraHash: 'abc123',
|
loraHash: 'abc123',
|
||||||
|
checkpointName: null,
|
||||||
|
checkpointHash: null,
|
||||||
recipeId: '42',
|
recipeId: '42',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,6 +184,8 @@ describe('RecipeManager', () => {
|
|||||||
|
|
||||||
expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraName');
|
expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraName');
|
||||||
expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraHash');
|
expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraHash');
|
||||||
|
expect(removeSessionItemMock).toHaveBeenCalledWith('checkpoint_to_recipe_filterCheckpointName');
|
||||||
|
expect(removeSessionItemMock).toHaveBeenCalledWith('checkpoint_to_recipe_filterCheckpointHash');
|
||||||
expect(removeSessionItemMock).toHaveBeenCalledWith('viewRecipeId');
|
expect(removeSessionItemMock).toHaveBeenCalledWith('viewRecipeId');
|
||||||
expect(pageState.customFilter.active).toBe(false);
|
expect(pageState.customFilter.active).toBe(false);
|
||||||
expect(indicator.classList.contains('hidden')).toBe(true);
|
expect(indicator.classList.contains('hidden')).toBe(true);
|
||||||
@@ -227,4 +236,36 @@ describe('RecipeManager', () => {
|
|||||||
await manager.refreshRecipes();
|
await manager.refreshRecipes();
|
||||||
expect(refreshRecipesMock).toHaveBeenCalledTimes(1);
|
expect(refreshRecipesMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores checkpoint recipe filter state and indicator text', async () => {
|
||||||
|
getSessionItemMock.mockImplementation((key) => {
|
||||||
|
const map = {
|
||||||
|
lora_to_recipe_filterLoraName: null,
|
||||||
|
lora_to_recipe_filterLoraHash: null,
|
||||||
|
checkpoint_to_recipe_filterCheckpointName: 'Flux Base',
|
||||||
|
checkpoint_to_recipe_filterCheckpointHash: 'ckpt123',
|
||||||
|
viewRecipeId: null,
|
||||||
|
};
|
||||||
|
return map[key] ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new RecipeManager();
|
||||||
|
await manager.initialize();
|
||||||
|
|
||||||
|
expect(pageState.customFilter).toEqual({
|
||||||
|
active: true,
|
||||||
|
loraName: null,
|
||||||
|
loraHash: null,
|
||||||
|
checkpointName: 'Flux Base',
|
||||||
|
checkpointHash: 'ckpt123',
|
||||||
|
recipeId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const indicator = document.getElementById('customFilterIndicator');
|
||||||
|
const filterText = indicator.querySelector('#customFilterText');
|
||||||
|
|
||||||
|
expect(filterText.innerHTML).toContain('Recipes using checkpoint:');
|
||||||
|
expect(filterText.innerHTML).toContain('Flux Base');
|
||||||
|
expect(filterText.getAttribute('title')).toBe('Flux Base');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ class StubRecipeScanner:
|
|||||||
self.cached_raw: List[Dict[str, Any]] = []
|
self.cached_raw: List[Dict[str, Any]] = []
|
||||||
self.recipes: Dict[str, Dict[str, Any]] = {}
|
self.recipes: Dict[str, Dict[str, Any]] = {}
|
||||||
self.removed: List[str] = []
|
self.removed: List[str] = []
|
||||||
|
self.last_paginated_params: Dict[str, Any] | None = None
|
||||||
|
self.lora_lookup: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
self.checkpoint_lookup: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
|
||||||
async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
|
async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
|
||||||
return None
|
return None
|
||||||
@@ -56,6 +59,7 @@ class StubRecipeScanner:
|
|||||||
return SimpleNamespace(raw_data=list(self.cached_raw))
|
return SimpleNamespace(raw_data=list(self.cached_raw))
|
||||||
|
|
||||||
async def get_paginated_data(self, **params: Any) -> Dict[str, Any]:
|
async def get_paginated_data(self, **params: Any) -> Dict[str, Any]:
|
||||||
|
self.last_paginated_params = params
|
||||||
items = [dict(item) for item in self.listing_items]
|
items = [dict(item) for item in self.listing_items]
|
||||||
page = int(params.get("page", 1))
|
page = int(params.get("page", 1))
|
||||||
page_size = int(params.get("page_size", 20))
|
page_size = int(params.get("page_size", 20))
|
||||||
@@ -70,6 +74,14 @@ class StubRecipeScanner:
|
|||||||
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||||
return self.recipes.get(recipe_id)
|
return self.recipes.get(recipe_id)
|
||||||
|
|
||||||
|
async def get_recipes_for_lora(self, lora_hash: str) -> List[Dict[str, Any]]:
|
||||||
|
return list(self.lora_lookup.get(lora_hash.lower(), []))
|
||||||
|
|
||||||
|
async def get_recipes_for_checkpoint(
|
||||||
|
self, checkpoint_hash: str
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
return list(self.checkpoint_lookup.get(checkpoint_hash.lower(), []))
|
||||||
|
|
||||||
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
|
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
|
||||||
candidate = Path(self.recipes_dir) / f"{recipe_id}.recipe.json"
|
candidate = Path(self.recipes_dir) / f"{recipe_id}.recipe.json"
|
||||||
return str(candidate) if candidate.exists() else None
|
return str(candidate) if candidate.exists() else None
|
||||||
@@ -132,6 +144,7 @@ class StubPersistenceService:
|
|||||||
self.save_calls: List[Dict[str, Any]] = []
|
self.save_calls: List[Dict[str, Any]] = []
|
||||||
self.delete_calls: List[str] = []
|
self.delete_calls: List[str] = []
|
||||||
self.move_calls: List[Dict[str, str]] = []
|
self.move_calls: List[Dict[str, str]] = []
|
||||||
|
self.update_calls: List[Dict[str, Any]] = []
|
||||||
self.save_result = SimpleNamespace(
|
self.save_result = SimpleNamespace(
|
||||||
payload={"success": True, "recipe_id": "stub-id"}, status=200
|
payload={"success": True, "recipe_id": "stub-id"}, status=200
|
||||||
)
|
)
|
||||||
@@ -182,7 +195,14 @@ class StubPersistenceService:
|
|||||||
|
|
||||||
async def update_recipe(
|
async def update_recipe(
|
||||||
self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]
|
self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]
|
||||||
) -> SimpleNamespace: # pragma: no cover - unused by smoke tests
|
) -> SimpleNamespace:
|
||||||
|
self.update_calls.append(
|
||||||
|
{
|
||||||
|
"recipe_scanner": recipe_scanner,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"updates": updates,
|
||||||
|
}
|
||||||
|
)
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
payload={"success": True, "recipe_id": recipe_id, "updates": updates},
|
payload={"success": True, "recipe_id": recipe_id, "updates": updates},
|
||||||
status=200,
|
status=200,
|
||||||
@@ -342,6 +362,47 @@ async def test_list_recipes_provides_file_urls(monkeypatch, tmp_path: Path) -> N
|
|||||||
assert payload["items"][0]["loras"] == []
|
assert payload["items"][0]["loras"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_recipes_passes_checkpoint_hash_filter(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
response = await harness.client.get("/api/lm/recipes?checkpoint_hash=ckpt123")
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["items"] == []
|
||||||
|
assert harness.scanner.last_paginated_params["checkpoint_hash"] == "ckpt123"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_recipes_for_checkpoint(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
harness.scanner.checkpoint_lookup["abc123"] = [
|
||||||
|
{"id": "recipe-1", "title": "Linked recipe"}
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await harness.client.get(
|
||||||
|
"/api/lm/recipes/for-checkpoint?hash=ABC123"
|
||||||
|
)
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload == {
|
||||||
|
"success": True,
|
||||||
|
"recipes": [{"id": "recipe-1", "title": "Linked recipe"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_recipes_for_checkpoint_requires_hash(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
response = await harness.client.get("/api/lm/recipes/for-checkpoint")
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert payload["success"] is False
|
||||||
|
|
||||||
|
|
||||||
async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) -> None:
|
async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) -> None:
|
||||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
form = FormData()
|
form = FormData()
|
||||||
@@ -509,6 +570,33 @@ async def test_import_remote_recipe_falls_back_to_request_base_model(
|
|||||||
assert provider_calls == ["77"]
|
assert provider_calls == ["77"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_recipe_accepts_gen_params(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
payload = {
|
||||||
|
"gen_params": {
|
||||||
|
"prompt": "updated prompt",
|
||||||
|
"negative_prompt": "updated negative",
|
||||||
|
"steps": 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await harness.client.put(
|
||||||
|
"/api/lm/recipe/recipe-42/update",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
data = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert data["success"] is True
|
||||||
|
assert harness.persistence.update_calls == [
|
||||||
|
{
|
||||||
|
"recipe_scanner": harness.scanner,
|
||||||
|
"recipe_id": "recipe-42",
|
||||||
|
"updates": payload,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
|
async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
|
||||||
async def fake_get_default_metadata_provider():
|
async def fake_get_default_metadata_provider():
|
||||||
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
|
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
|
||||||
|
|||||||
@@ -184,7 +184,10 @@ async def test_parse_metadata_populates_checkpoint_and_rewrites_thumbnails(monke
|
|||||||
assert result["model"] is not None
|
assert result["model"] is not None
|
||||||
assert result["model"]["name"] == "Checkpoint Example"
|
assert result["model"]["name"] == "Checkpoint Example"
|
||||||
assert result["model"]["type"] == "checkpoint"
|
assert result["model"]["type"] == "checkpoint"
|
||||||
assert result["model"]["thumbnailUrl"] == "https://image.civitai.com/checkpoints/width=450,optimized=true"
|
assert (
|
||||||
|
result["model"]["thumbnailUrl"]
|
||||||
|
== "https://image.civitai.com/checkpoints/width=450,optimized=true"
|
||||||
|
)
|
||||||
assert result["model"]["modelId"] == 111
|
assert result["model"]["modelId"] == 111
|
||||||
assert result["model"]["size"] == 1024 * 1024
|
assert result["model"]["size"] == 1024 * 1024
|
||||||
assert result["model"]["hash"] == "ffaa0011"
|
assert result["model"]["hash"] == "ffaa0011"
|
||||||
@@ -192,5 +195,106 @@ async def test_parse_metadata_populates_checkpoint_and_rewrites_thumbnails(monke
|
|||||||
|
|
||||||
assert result["loras"]
|
assert result["loras"]
|
||||||
assert result["loras"][0]["name"] == "Example Lora Model"
|
assert result["loras"][0]["name"] == "Example Lora Model"
|
||||||
assert result["loras"][0]["thumbnailUrl"] == "https://image.civitai.com/loras/width=450,optimized=true"
|
assert (
|
||||||
|
result["loras"][0]["thumbnailUrl"]
|
||||||
|
== "https://image.civitai.com/loras/width=450,optimized=true"
|
||||||
|
)
|
||||||
assert result["loras"][0]["hash"] == "abc123"
|
assert result["loras"][0]["hash"] == "abc123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_handles_modelVersionIds(monkeypatch):
|
||||||
|
"""Test that modelVersionIds from Civitai image API are properly processed."""
|
||||||
|
lora_info_1 = {
|
||||||
|
"id": 2398829,
|
||||||
|
"modelId": 123456,
|
||||||
|
"model": {"name": "Dance LoRA 1", "type": "lora"},
|
||||||
|
"name": "Version 1.0",
|
||||||
|
"images": [{"url": "https://image.civitai.com/lora1/original=true"}],
|
||||||
|
"baseModel": "SDXL",
|
||||||
|
"downloadUrl": "https://civitai.com/lora1/download",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"type": "Model",
|
||||||
|
"primary": True,
|
||||||
|
"sizeKB": 10240,
|
||||||
|
"name": "dance_lora_1.safetensors",
|
||||||
|
"hashes": {"SHA256": "aabbccdd0011"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
lora_info_2 = {
|
||||||
|
"id": 2398838,
|
||||||
|
"modelId": 123457,
|
||||||
|
"model": {"name": "Style LoRA 2", "type": "lora"},
|
||||||
|
"name": "Version 2.0",
|
||||||
|
"images": [{"url": "https://image.civitai.com/lora2/original=true"}],
|
||||||
|
"baseModel": "SDXL",
|
||||||
|
"downloadUrl": "https://civitai.com/lora2/download",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"type": "Model",
|
||||||
|
"primary": True,
|
||||||
|
"sizeKB": 20480,
|
||||||
|
"name": "style_lora_2.safetensors",
|
||||||
|
"hashes": {"SHA256": "aabbccdd0022"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
if version_id == "2398829":
|
||||||
|
return lora_info_1, None
|
||||||
|
if version_id == "2398838":
|
||||||
|
return lora_info_2, None
|
||||||
|
return None, "Model not found"
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.civitai_image.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = CivitaiApiMetadataParser()
|
||||||
|
|
||||||
|
# This simulates the metadata from Civitai image API where modelVersionIds
|
||||||
|
# is at the root level and meta only contains basic prompt info
|
||||||
|
metadata = {
|
||||||
|
"id": 109882763,
|
||||||
|
"meta": {
|
||||||
|
"id": 109882763,
|
||||||
|
"meta": {"prompt": "A woman does the hip bump dance."},
|
||||||
|
},
|
||||||
|
"modelVersionIds": [2398829, 2398838],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert parser.is_metadata_matching(metadata)
|
||||||
|
|
||||||
|
result = await parser.parse_metadata(metadata)
|
||||||
|
|
||||||
|
# Verify both LoRAs were created from modelVersionIds
|
||||||
|
assert len(result["loras"]) == 2
|
||||||
|
|
||||||
|
# Check first LoRA
|
||||||
|
lora1 = result["loras"][0]
|
||||||
|
assert lora1["id"] == 2398829
|
||||||
|
assert lora1["name"] == "Dance LoRA 1"
|
||||||
|
assert lora1["type"] == "lora"
|
||||||
|
assert lora1["hash"] == "aabbccdd0011"
|
||||||
|
assert lora1["baseModel"] == "SDXL"
|
||||||
|
assert (
|
||||||
|
lora1["thumbnailUrl"]
|
||||||
|
== "https://image.civitai.com/lora1/width=450,optimized=true"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check second LoRA
|
||||||
|
lora2 = result["loras"][1]
|
||||||
|
assert lora2["id"] == 2398838
|
||||||
|
assert lora2["name"] == "Style LoRA 2"
|
||||||
|
assert lora2["type"] == "lora"
|
||||||
|
assert lora2["hash"] == "aabbccdd0022"
|
||||||
|
assert lora2["baseModel"] == "SDXL"
|
||||||
|
|||||||
@@ -313,6 +313,75 @@ async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner):
|
|||||||
assert recipe["checkpoint"]["file_name"] == "by-id"
|
assert recipe["checkpoint"]["file_name"] == "by-id"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
image_path = Path(config.loras_roots[0]) / "checkpoint-filter.webp"
|
||||||
|
await scanner.add_recipe(
|
||||||
|
{
|
||||||
|
"id": "checkpoint-match",
|
||||||
|
"file_path": str(image_path),
|
||||||
|
"title": "Checkpoint Match",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [],
|
||||||
|
"checkpoint": {
|
||||||
|
"name": "flux-base.safetensors",
|
||||||
|
"hash": "ABC123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await scanner.add_recipe(
|
||||||
|
{
|
||||||
|
"id": "checkpoint-miss",
|
||||||
|
"file_path": str(Path(config.loras_roots[0]) / "checkpoint-miss.webp"),
|
||||||
|
"title": "Checkpoint Miss",
|
||||||
|
"modified": 1.0,
|
||||||
|
"created_date": 1.0,
|
||||||
|
"loras": [],
|
||||||
|
"checkpoint": {
|
||||||
|
"name": "other.safetensors",
|
||||||
|
"hash": "zzz999",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
result = await scanner.get_paginated_data(
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
checkpoint_hash="abc123",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [item["id"] for item in result["items"]] == ["checkpoint-match"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_recipes_for_checkpoint_matches_hash_case_insensitively(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
image_path = Path(config.loras_roots[0]) / "checkpoint-linked.webp"
|
||||||
|
await scanner.add_recipe(
|
||||||
|
{
|
||||||
|
"id": "checkpoint-linked",
|
||||||
|
"file_path": str(image_path),
|
||||||
|
"title": "Checkpoint Linked",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [],
|
||||||
|
"checkpoint": {
|
||||||
|
"name": "flux-base.safetensors",
|
||||||
|
"hash": "ABC123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
recipes = await scanner.get_recipes_for_checkpoint("abc123")
|
||||||
|
|
||||||
|
assert len(recipes) == 1
|
||||||
|
assert recipes[0]["id"] == "checkpoint-linked"
|
||||||
|
assert recipes[0]["checkpoint"]["hash"] == "ABC123"
|
||||||
|
|
||||||
|
|
||||||
def test_enrich_uses_version_index_when_hash_missing(recipe_scanner):
|
def test_enrich_uses_version_index_when_hash_missing(recipe_scanner):
|
||||||
scanner, stub = recipe_scanner
|
scanner, stub = recipe_scanner
|
||||||
version_id = 77
|
version_id = 77
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ from types import SimpleNamespace
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from py.services.recipes.analysis_service import RecipeAnalysisService
|
from py.services.recipes.analysis_service import RecipeAnalysisService
|
||||||
from py.services.recipes.errors import RecipeDownloadError, RecipeNotFoundError
|
from py.services.recipes.errors import (
|
||||||
|
RecipeDownloadError,
|
||||||
|
RecipeNotFoundError,
|
||||||
|
RecipeValidationError,
|
||||||
|
)
|
||||||
from py.services.recipes.persistence_service import RecipePersistenceService
|
from py.services.recipes.persistence_service import RecipePersistenceService
|
||||||
|
|
||||||
|
|
||||||
@@ -486,6 +490,50 @@ async def test_move_recipe_updates_paths(tmp_path):
|
|||||||
assert stored["file_path"] == result.payload["new_file_path"]
|
assert stored["file_path"] == result.payload["new_file_path"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_recipe_accepts_gen_params() -> None:
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def update_recipe_metadata(self, recipe_id: str, updates: dict[str, object]):
|
||||||
|
self.calls.append((recipe_id, updates))
|
||||||
|
return True
|
||||||
|
|
||||||
|
scanner = DummyScanner()
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=DummyExifUtils(),
|
||||||
|
card_preview_width=512,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
updates = {"gen_params": {"prompt": "updated prompt", "steps": 28}}
|
||||||
|
result = await service.update_recipe(
|
||||||
|
recipe_scanner=scanner,
|
||||||
|
recipe_id="recipe-1",
|
||||||
|
updates=updates,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.payload["success"] is True
|
||||||
|
assert scanner.calls == [("recipe-1", updates)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_recipe_rejects_non_object_gen_params() -> None:
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=DummyExifUtils(),
|
||||||
|
card_preview_width=512,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RecipeValidationError, match="gen_params must be an object"):
|
||||||
|
await service.update_recipe(
|
||||||
|
recipe_scanner=SimpleNamespace(),
|
||||||
|
recipe_id="recipe-1",
|
||||||
|
updates={"gen_params": "invalid"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_analyze_remote_video(tmp_path):
|
async def test_analyze_remote_video(tmp_path):
|
||||||
exif_utils = DummyExifUtils()
|
exif_utils = DummyExifUtils()
|
||||||
|
|||||||
Reference in New Issue
Block a user