feat(recipe): add editable prompts in recipe modal (#869)

This commit is contained in:
Will Miao
2026-03-31 14:04:02 +08:00
parent 331889d872
commit 3dc10b1404
17 changed files with 638 additions and 28 deletions

View File

@@ -1510,6 +1510,9 @@
"nameUpdated": "Rezeptname erfolgreich aktualisiert",
"tagsUpdated": "Rezept-Tags 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",
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",

View File

@@ -1510,6 +1510,9 @@
"nameUpdated": "Recipe name updated successfully",
"tagsUpdated": "Recipe tags 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",
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
"copyFailed": "Error copying recipe syntax: {message}",

View File

@@ -1510,6 +1510,9 @@
"nameUpdated": "Nombre de receta actualizado exitosamente",
"tagsUpdated": "Etiquetas de receta actualizadas 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",
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}",
"copyFailed": "Error copiando sintaxis de receta: {message}",

View File

@@ -1510,6 +1510,9 @@
"nameUpdated": "Nom 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",
"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",
"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}",

View File

@@ -1510,6 +1510,9 @@
"nameUpdated": "שם המתכון עודכן בהצלחה",
"tagsUpdated": "תגיות המתכון עודכנו בהצלחה",
"sourceUrlUpdated": "כתובת ה-URL המקורית עודכנה בהצלחה",
"promptUpdated": "הפרומפט עודכן בהצלחה",
"negativePromptUpdated": "הפרומפט השלילי עודכן בהצלחה",
"promptEditorHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
"noRecipeId": "אין מזהה מתכון זמין",
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",

View File

@@ -1510,6 +1510,9 @@
"nameUpdated": "レシピ名が正常に更新されました",
"tagsUpdated": "レシピタグが正常に更新されました",
"sourceUrlUpdated": "ソースURLが正常に更新されました",
"promptUpdated": "プロンプトが正常に更新されました",
"negativePromptUpdated": "ネガティブプロンプトが正常に更新されました",
"promptEditorHint": "Enterキーで保存、Shift+Enterで改行",
"noRecipeId": "レシピIDが利用できません",
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
"copyFailed": "レシピ構文のコピーエラー:{message}",

View File

@@ -1510,6 +1510,9 @@
"nameUpdated": "레시피 이름이 성공적으로 업데이트되었습니다",
"tagsUpdated": "레시피 태그가 성공적으로 업데이트되었습니다",
"sourceUrlUpdated": "소스 URL이 성공적으로 업데이트되었습니다",
"promptUpdated": "프롬프트가 성공적으로 업데이트되었습니다",
"negativePromptUpdated": "네거티브 프롬프트가 성공적으로 업데이트되었습니다",
"promptEditorHint": "Enter 키를 눌러 저장, Shift+Enter로 새 줄",
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
"copyFailed": "레시피 문법 복사 오류: {message}",

View File

@@ -1510,6 +1510,9 @@
"nameUpdated": "Название рецепта успешно обновлено",
"tagsUpdated": "Теги рецепта успешно обновлены",
"sourceUrlUpdated": "Исходный URL успешно обновлен",
"promptUpdated": "Промпт успешно обновлён",
"negativePromptUpdated": "Негативный промпт успешно обновлён",
"promptEditorHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
"noRecipeId": "ID рецепта недоступен",
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",

View File

@@ -1510,6 +1510,9 @@
"nameUpdated": "配方名称更新成功",
"tagsUpdated": "配方标签更新成功",
"sourceUrlUpdated": "来源 URL 更新成功",
"promptUpdated": "提示词更新成功",
"negativePromptUpdated": "负面提示词更新成功",
"promptEditorHint": "按 Enter 保存Shift+Enter 换行",
"noRecipeId": "无配方 ID",
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
"copyFailed": "复制配方语法出错:{message}",

View File

@@ -1510,6 +1510,9 @@
"nameUpdated": "配方名稱已更新",
"tagsUpdated": "配方標籤已更新",
"sourceUrlUpdated": "來源網址已更新",
"promptUpdated": "提示詞更新成功",
"negativePromptUpdated": "負面提示詞更新成功",
"promptEditorHint": "按 Enter 儲存Shift+Enter 換行",
"noRecipeId": "無配方 ID",
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
"copyFailed": "複製配方語法錯誤:{message}",

View File

@@ -173,11 +173,23 @@ class RecipePersistenceService:
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
"""Update persisted metadata for a recipe."""
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level", "favorite")):
raise RecipeValidationError(
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or 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(
"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)
if not success:
raise RecipeNotFoundError("Recipe not found or update failed")

View File

@@ -424,6 +424,7 @@
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.param-header label {
@@ -431,7 +432,14 @@
color: var(--text-color);
}
.copy-btn {
.param-actions {
display: flex;
align-items: center;
gap: 4px;
}
.copy-btn,
.edit-btn {
background: none;
border: none;
color: var(--text-color);
@@ -442,7 +450,8 @@
transition: all 0.2s;
}
.copy-btn:hover {
.copy-btn:hover,
.edit-btn:hover {
opacity: 1;
background: var(--lora-surface);
}
@@ -461,6 +470,48 @@
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-params {
display: flex;

View File

@@ -9,11 +9,13 @@ import { MODEL_TYPES } from '../api/apiConfig.js';
class RecipeModal {
constructor() {
this.promptEditorState = {};
this.init();
}
init() {
this.setupCopyButtons();
this.setupPromptEditors();
// Set up tooltip positioning handlers after DOM is ready
document.addEventListener('DOMContentLoaded', () => {
this.setupTooltipPositioning();
@@ -87,6 +89,7 @@ class RecipeModal {
showRecipeDetails(recipe) {
// Store the full recipe for editing
this.currentRecipe = recipe;
this.resetPromptEditors();
// Set modal title with edit icon
const modalTitle = document.getElementById('recipeModalTitle');
@@ -300,20 +303,19 @@ class RecipeModal {
const promptElement = document.getElementById('recipePrompt');
const negativePromptElement = document.getElementById('recipeNegativePrompt');
const otherParamsElement = document.getElementById('recipeOtherParams');
const promptInput = document.getElementById('recipePromptInput');
const negativePromptInput = document.getElementById('recipeNegativePromptInput');
if (recipe.gen_params) {
// Set prompt
if (promptElement && recipe.gen_params.prompt) {
promptElement.textContent = recipe.gen_params.prompt;
} else if (promptElement) {
promptElement.textContent = 'No prompt information available';
this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available');
this.renderPromptContent(negativePromptElement, recipe.gen_params.negative_prompt, 'No negative prompt information available');
if (promptInput) {
promptInput.value = recipe.gen_params.prompt || '';
}
// Set negative prompt
if (negativePromptElement && recipe.gen_params.negative_prompt) {
negativePromptElement.textContent = recipe.gen_params.negative_prompt;
} else if (negativePromptElement) {
negativePromptElement.textContent = 'No negative prompt information available';
if (negativePromptInput) {
negativePromptInput.value = recipe.gen_params.negative_prompt || '';
}
// Set other parameters
@@ -343,8 +345,10 @@ class RecipeModal {
}
} else {
// No generation parameters available
if (promptElement) promptElement.textContent = 'No prompt information available';
if (negativePromptElement) promptElement.textContent = 'No negative prompt information available';
this.renderPromptContent(promptElement, '', 'No 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>';
}
@@ -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
setupSourceUrlHandlers() {
const sourceUrlContainer = document.querySelector('.source-url-container');
const sourceUrlEditor = document.querySelector('.source-url-editor');
if (!sourceUrlContainer || !sourceUrlEditor) {
return;
}
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn');
const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn');
const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn');
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
if (!sourceUrlText || !sourceUrlEditBtn || !sourceUrlCancelBtn || !sourceUrlSaveBtn || !sourceUrlInput) {
return;
}
// Show editor on edit button click
sourceUrlEditBtn.addEventListener('click', () => {
sourceUrlContainer.classList.add('hide');
@@ -782,14 +972,14 @@ class RecipeModal {
if (copyPromptBtn) {
copyPromptBtn.addEventListener('click', () => {
const promptText = document.getElementById('recipePrompt').textContent;
const promptText = this.currentRecipe?.gen_params?.prompt || '';
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
});
}
if (copyNegativePromptBtn) {
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');
});
}

View File

@@ -29,22 +29,52 @@
<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"
placeholder="Enter prompt"
></textarea>
<div class="param-editor-hint">
{{ t('toast.recipes.promptEditorHint') }}
</div>
</div>
</div>
<!-- Negative Prompt -->
<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"
placeholder="Enter negative prompt"
></textarea>
<div class="param-editor-hint">
{{ t('toast.recipes.promptEditorHint') }}
</div>
</div>
</div>
<!-- Other Parameters -->

View File

@@ -245,16 +245,28 @@ describe('Interaction-level regression coverage', () => {
<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>
@@ -324,6 +336,208 @@ describe('Interaction-level regression coverage', () => {
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 () => {
document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu">

View File

@@ -132,6 +132,7 @@ class StubPersistenceService:
self.save_calls: List[Dict[str, Any]] = []
self.delete_calls: List[str] = []
self.move_calls: List[Dict[str, str]] = []
self.update_calls: List[Dict[str, Any]] = []
self.save_result = SimpleNamespace(
payload={"success": True, "recipe_id": "stub-id"}, status=200
)
@@ -182,7 +183,14 @@ class StubPersistenceService:
async def update_recipe(
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(
payload={"success": True, "recipe_id": recipe_id, "updates": updates},
status=200,
@@ -509,6 +517,33 @@ async def test_import_remote_recipe_falls_back_to_request_base_model(
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 fake_get_default_metadata_provider():
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))

View File

@@ -7,7 +7,11 @@ from types import SimpleNamespace
import pytest
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
@@ -486,6 +490,50 @@ async def test_move_recipe_updates_paths(tmp_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
async def test_analyze_remote_video(tmp_path):
exif_utils = DummyExifUtils()