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

@@ -245,16 +245,28 @@ describe('Interaction-level regression coverage', () => {
<div class="param-group info-item">
<div class="param-header">
<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 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>
<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 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()