fix(editor): prompt templates are editable, deletable and dismissable

The bar never recorded which template was applied, so there was nothing to
rename, delete or leave; deletePromptTemplate had no caller at all. Clearing
is now explicit because applyGlobalPromptQuiet refuses empty text.
This commit is contained in:
digital-garbage
2026-08-22 22:09:35 +03:00
parent 95fb87db0d
commit 98f706c815
5 changed files with 139 additions and 10 deletions
+9
View File
@@ -86,6 +86,15 @@
looked exactly like one where the feature was switched off.
### Fixed
- **Prompt templates can be renamed, deleted, and turned off.** The Composer's Templates bar
applied a template and immediately forgot it, so there was no state to leave and no way to
leave it — and clearing the prompt box by hand did nothing, because an empty global prompt
is refused (an empty parse would wipe the timeline on a stray keystroke). The bar now shows
which template is applied, offers `— None —` to clear the prompt as a deliberate, undoable
action, and gives the applied template Rename and Delete. Saving pre-fills the applied
name, so updating a template is the default gesture rather than a trick. Deleting one
leaves the prompt alone.
- **"No, I'll use my own pipeline" left the setup modal on screen.** Picking a model family
starts a chain of requests that reopens the modal with fresh prerequisites; dismissing it
mid-flight closed it, then the reply landed and put it straight back — and since opting out
+5
View File
@@ -601,6 +601,10 @@ class Project:
# Saved global-prompt templates: [{"name": str, "prompt": str, "variables": [...]}]. Selecting
# one in the Composer applies its prompt + variables; loaded with the project (no Load button).
prompt_templates: list = field(default_factory=list)
# Which saved template the global prompt currently came from, so the Composer can show
# it as selected and offer rename / update / delete for it. "" = none applied; the
# prompt is whatever the user typed.
active_prompt_template: str = ""
# Refinement key for this project's runs. Feeds the FunPackRefinementKeyLoader (Studio /
# Chain Sampler / SaveRefinementLatent). "default" = the keyless/default store. Shortcuts
# bound to a non-default key layer their own per-scene training on top of this.
@@ -677,6 +681,7 @@ class Project:
h3_references=list(d.get("h3_references") or []),
references=[str(r) for r in (d.get("references") or []) if r],
prompt_templates=list(d.get("prompt_templates") or []),
active_prompt_template=str(d.get("active_prompt_template") or ""),
refinement_key=str(d.get("refinement_key") or "default"),
keep_original_audio=bool(d.get("keep_original_audio", True)),
audio_tracks=list(d.get("audio_tracks") or []),
+67 -8
View File
@@ -256,28 +256,87 @@
let varHintEl = null; // undeclared / cycle hint line under the prompt
// ── templates bar (above the global prompt) ─────────────────────────────────────
const TPL_NONE = "__none__";
function composeTemplatesBar() {
const bar = el("div", "compose-templates");
const tpls = S.promptTemplates() || [];
// Which template the prompt came from. Without it the select forgot its own answer the
// instant it was given, so there was no state to leave — and so no way to leave it.
const active = S.get().project?.active_prompt_template || "";
const known = tpls.some((t) => t && t.name === active);
const sel = el("select", "lib-in compose-tpl-select");
const ph = new Option("Templates…", ""); ph.disabled = true; ph.selected = true; sel.append(ph);
(S.promptTemplates() || []).forEach((t) => {
const ph = new Option("Templates…", ""); ph.disabled = true; sel.append(ph);
sel.append(new Option("— None (clear the prompt) —", TPL_NONE));
tpls.forEach((t) => {
const preview = String(t.prompt || "").replace(/\s+/g, " ").slice(0, 48);
const o = new Option(`${t.name}${preview ? " — " + preview : ""}`, t.name);
if (t.name === active) o.selected = true;
sel.append(o);
});
if (!known) ph.selected = true;
sel.title = "Apply a saved global prompt + its variables";
sel.onchange = () => { const n = sel.value; if (n) S.applyPromptTemplate(n).then(render); };
sel.onchange = () => {
const n = sel.value;
if (!n) return;
if (n === TPL_NONE) {
if (!confirm(
"Clear the global prompt and stop using the current template?\n\n"
+ "This empties every scene's text. Variables are left alone — edit them in "
+ "Variables below. Undo restores it."
)) { render(); return; }
S.clearGlobalPrompt();
render();
return;
}
S.applyPromptTemplate(n).then(render);
};
bar.append(sel);
const save = el("button", "btn ghost tiny", "Save");
save.title = "Save the current global prompt + variables as a template";
const currentText = () => (composeTextarea ? composeTextarea.value
: (S.get().project?.global_prompt || ""));
// Saving under an existing name overwrites it, so pre-filling the applied template's
// name makes "update this template" the default gesture rather than a hidden trick.
const save = el("button", "btn ghost tiny", known ? "Update…" : "Save");
save.title = known
? `Overwrite "${active}" with the current prompt + variables, or save under a new name`
: "Save the current global prompt + variables as a template";
save.onclick = () => {
const name = (window.prompt("Template name:") || "").trim();
const name = (window.prompt("Template name:", known ? active : "") || "").trim();
if (!name) return;
const txt = composeTextarea ? composeTextarea.value : (S.get().project?.global_prompt || "");
S.savePromptTemplate(name, txt);
S.savePromptTemplate(name, currentText());
render();
};
bar.append(save);
// Rename and delete act on the applied template. They are the two operations the bar
// never offered: a template could only be replaced by saving over its own name, and
// nothing anywhere called the store's deletePromptTemplate.
if (known) {
const ren = el("button", "btn ghost tiny", "✎");
ren.title = `Rename "${active}"`;
ren.onclick = () => {
const next = (window.prompt("Rename template:", active) || "").trim();
if (!next || next === active) return;
if (!S.renamePromptTemplate(active, next)) {
alert(`A template called "${next}" already exists.`);
return;
}
render();
};
bar.append(ren);
const del = el("button", "btn ghost tiny danger", "×");
del.title = `Delete "${active}"`;
del.onclick = () => {
if (!confirm(`Delete the template "${active}"?\n\nThe prompt itself stays as it is.`)) return;
S.deletePromptTemplate(active);
render();
};
bar.append(del);
}
return bar;
}
+41 -2
View File
@@ -1101,16 +1101,54 @@
}
function deletePromptTemplate(name) {
if (!state.project) return;
patchProject({ prompt_templates: (state.project.prompt_templates || []).filter((t) => t && t.name !== name) });
const patch = {
prompt_templates: (state.project.prompt_templates || []).filter((t) => t && t.name !== name),
};
// Deleting the one in use leaves nothing selected — the prompt itself is untouched,
// it is just no longer "from" a template.
if (state.project.active_prompt_template === name) patch.active_prompt_template = "";
patchProject(patch);
}
function renamePromptTemplate(oldName, newName) {
if (!state.project) return false;
const nm = String(newName || "").trim();
if (!nm || nm === oldName) return false;
const list = state.project.prompt_templates || [];
if (list.some((t) => t && t.name === nm)) return false; // caller reports the clash
const patch = {
prompt_templates: list.map((t) => (t && t.name === oldName ? { ...t, name: nm } : t)),
};
if (state.project.active_prompt_template === oldName) patch.active_prompt_template = nm;
patchProject(patch);
return true;
}
async function applyPromptTemplate(name) {
if (!state.project) return;
const tpl = (state.project.prompt_templates || []).find((t) => t && t.name === name);
if (!tpl) return;
// Restore the saved variables first, then distribute the prompt (re-splits the timeline).
patchProjectQuiet({ variables: JSON.parse(JSON.stringify(tpl.variables || [])) });
patchProjectQuiet({
variables: JSON.parse(JSON.stringify(tpl.variables || [])),
active_prompt_template: name,
});
await applyGlobalPromptQuiet(tpl.prompt || "");
}
// Back to no template. applyGlobalPromptQuiet REFUSES empty text (an empty parse would
// wipe the timeline as a side effect of a stray keystroke), which is why clearing the
// box by hand never did anything — the scene texts it is built from stayed put. Clearing
// is therefore its own deliberate operation, and it is recorded for undo.
function clearGlobalPrompt() {
if (!state.project) return false;
// Drop any debounced edit first. It carries the text that is being cleared, and would
// otherwise land half a second later and redistribute it back onto the scenes.
if (_globalApplyTimer) { clearTimeout(_globalApplyTimer); _globalApplyTimer = null; }
_pendingGlobalPromptText = null;
_historyRecord();
(state.project.scenes || []).forEach((sc) => { sc.text = ""; });
patchProject({ active_prompt_template: "" });
syncGlobalPromptFromTimeline();
return true;
}
function scene(id) { return state.project?.scenes.find((s) => s.id === id) || null; }
@@ -4449,6 +4487,7 @@
applyEnginePreset, ENGINE_PRESETS, undo, redo,
refreshPreview, syncFromPreview, applyGlobalPromptQuiet, scheduleGlobalPromptApply, globalPromptApplyPending, buildGlobalPromptFromTimeline, syncGlobalPromptFromTimeline, generate: _clockedGenerate, generateMontage: _clockedMontage, generateSelected: _clockedSelected, genElapsed, selectedSceneCount, renderFinal, exportSelected, saveSelectedToMediaBin, clipSaveableToMediaBin, interrupt, loadModels, loadImageTargets, setModelInput, setModelBypass, setModelLink, clearNotice,
projectVariables, setProjectVariables, promptTemplates, savePromptTemplate, deletePromptTemplate, applyPromptTemplate,
renamePromptTemplate, clearGlobalPrompt,
setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, unsetSamplerInput, setStudioInput, setStudioInputNow,
loadMedia, uploadMedia, deleteMedia, deleteMediaMany, renameMedia, previewMedia, clearMediaPreview, assignMediaToScene, exportMediaAsset,
loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, clearShortcuts, addCategory,
+17
View File
@@ -438,3 +438,20 @@ def test_projects_store_crud(tmp_path, monkeypatch):
assert any(x["id"] == created.id for x in projects.list_projects())
assert projects.delete(created.id) is True
assert projects.get(created.id) is None
def test_active_prompt_template_round_trips():
"""The Composer marks which template the prompt came from; from_dict reads only known
keys, so an unmodelled field would be silently dropped on every save."""
from movie_editor.backend.timeline import Project
p = Project.from_dict({"id": "p", "name": "n", "active_prompt_template": "Cinematic"})
assert p.active_prompt_template == "Cinematic"
assert Project.from_dict(p.to_dict()).active_prompt_template == "Cinematic"
def test_active_prompt_template_defaults_to_none_applied():
from movie_editor.backend.timeline import Project
assert Project.from_dict({"id": "p", "name": "n"}).active_prompt_template == ""
# A stored null must not become the string "None".
assert Project.from_dict({"id": "p", "name": "n",
"active_prompt_template": None}).active_prompt_template == ""