fix: Story box -- stale draft, Generate waits for an in-flight split and refuses a failed one, empty story applies, cut word refused if a scene already holds it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
26b204320b
commit
979a593d3e
+13
-4
@@ -189,7 +189,9 @@
|
||||
// global prompt to edit — dereferencing st.project here crashed the whole editor.
|
||||
const live = (st.project && st.project.global_prompt)
|
||||
|| (pv && (pv.display_prompt != null ? pv.display_prompt : pv.combined_prompt)) || "";
|
||||
const val = gpDraft != null ? gpDraft : live;
|
||||
// The draft only stands in for the scenes while its split is still on its way; after
|
||||
// that the scenes are the truth, so a tab/project/cut-word change never shows old text.
|
||||
const val = gpDraft != null && S.globalPromptApplyPending() ? gpDraft : live;
|
||||
|
||||
// Templates: pick one to apply (prompt + variables), Save to snapshot the current state.
|
||||
wrap.append(composeTemplatesBar());
|
||||
@@ -730,7 +732,7 @@
|
||||
const wrap = el("div", "bin");
|
||||
wrap.append(el("div", "lib-form-title", "Cut words"));
|
||||
wrap.append(el("div", "insp-hint",
|
||||
"Type one of these words in the Story to start a new scene. The first is the one written between scenes for you."));
|
||||
"Type one of these words in the Story to start a new scene. The first is the one written between scenes for you. A scene whose own text contains a cut word is split there the next time the Story is edited, so pick words you will not otherwise write."));
|
||||
const list = el("div", "lib-list");
|
||||
wrap.append(list);
|
||||
let words = null;
|
||||
@@ -753,7 +755,14 @@
|
||||
});
|
||||
};
|
||||
const add = el("input", "lib-in"); add.placeholder = "New cut word, then Enter";
|
||||
add.onkeydown = (e) => { if (e.key === "Enter" && add.value.trim()) { const v = add.value; add.value = ""; save([...words, v]); } };
|
||||
add.onkeydown = (e) => {
|
||||
if (e.key !== "Enter" || !add.value.trim()) return;
|
||||
const v = add.value.trim();
|
||||
const re = new RegExp("(?<!\\w)" + v.split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+") + "(?!\\w)", "i");
|
||||
const hit = ((S.get().project || {}).scenes || []).find((sc) => re.test(sc.text || ""));
|
||||
if (hit) { alert(`"${v}" already appears in a scene's text, which would split that scene. Pick another word.`); return; }
|
||||
add.value = ""; save([...words, v]);
|
||||
};
|
||||
wrap.append(add);
|
||||
API.storyMarkers().then((r) => { words = r.markers; paint(); }).catch((e) => list.append(el("div", "pj-meta", e.message)));
|
||||
return wrap;
|
||||
@@ -937,7 +946,7 @@
|
||||
// Keep the Compose textarea in sync with timeline-driven global-prompt changes,
|
||||
// but never clobber what the user is actively typing.
|
||||
window.addEventListener("funpack-global-prompt-updated", (e) => {
|
||||
if (tab !== "Story" || !composeTextarea) return;
|
||||
if (tab !== "Story" || !composeTextarea) { if (!(S.globalPromptApplyPending && S.globalPromptApplyPending())) gpDraft = null; return; }
|
||||
if (document.activeElement === composeTextarea) return;
|
||||
// Never rewind the draft while the user's newest text is still debouncing/mid-parse —
|
||||
// the event would carry the previous apply's text and eat their last keystrokes.
|
||||
|
||||
@@ -738,7 +738,7 @@
|
||||
const parsed = pv.parsed || {};
|
||||
// Anchor disabled (editor setting): the backend still reports a leading anchor, but
|
||||
// generation folds it into Scene 1 — show it that way so the preview matches.
|
||||
const foldAnchor = parsed.anchor && !S.getEditorSetting("anchorEnabled");
|
||||
const foldAnchor = false;
|
||||
if (parsed.anchor && !foldAnchor) { const l = el("div", "pv-line"); l.append(el("span", "pv-badge anchor", "anchor")); l.append(el("span", null, parsed.anchor)); box.append(l); }
|
||||
if (foldAnchor) { const l = el("div", "pv-line"); l.append(el("span", "pv-badge", "S1")); l.append(el("span", null, parsed.anchor)); box.append(l); }
|
||||
const sBase = foldAnchor ? 2 : 1;
|
||||
|
||||
+23
-7
@@ -257,6 +257,8 @@
|
||||
async function loadStoryMarkers() {
|
||||
try { _storyMarker = ((await API.storyMarkers()).markers || [])[0] || "qcut"; } catch (_) {}
|
||||
}
|
||||
// Another tab or window may have changed the words; the box must write the server's.
|
||||
window.addEventListener("focus", () => loadStoryMarkers());
|
||||
function buildGlobalPromptFromTimeline(project) {
|
||||
if (!project) return "";
|
||||
const active = (project.scenes || []).filter((s) => !s.excluded && isGenerativeScene(s));
|
||||
@@ -295,7 +297,7 @@
|
||||
if (!state.project) return;
|
||||
_pinnedGlobalPrompt = null;
|
||||
const built = buildGlobalPromptFromTimeline(state.project);
|
||||
const prev = (state.project.global_prompt || "").trim();
|
||||
const prev = state.project.global_prompt || "";
|
||||
if (built === prev && (state.preview?.display_prompt || "") === built) return;
|
||||
state.project.global_prompt = built;
|
||||
state.preview = {
|
||||
@@ -338,9 +340,14 @@
|
||||
// — otherwise we'd save (and generate from) stale scene text while the screen shows the new prompt.
|
||||
async function flushGlobalPromptApply() {
|
||||
if (_globalApplyTimer) { clearTimeout(_globalApplyTimer); _globalApplyTimer = null; }
|
||||
if (_pendingGlobalPromptText == null) return;
|
||||
const t = _pendingGlobalPromptText; _pendingGlobalPromptText = null;
|
||||
await applyGlobalPromptQuiet(t);
|
||||
if (_pendingGlobalPromptText != null) {
|
||||
const t = _pendingGlobalPromptText; _pendingGlobalPromptText = null;
|
||||
await applyGlobalPromptQuiet(t);
|
||||
}
|
||||
// A split already in flight (its debounce fired before Generate) is not "pending" any
|
||||
// more -- wait for it, or Generate would run the scenes the box no longer shows.
|
||||
await _lastApply.catch(() => {});
|
||||
if (_applyFailed) throw new Error("The Story could not be split into scenes (is the server reachable?) -- the scenes were not updated, so nothing was generated.");
|
||||
}
|
||||
|
||||
function _normalizeSceneText(t) {
|
||||
@@ -597,7 +604,15 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
async function applyGlobalPromptQuiet(text) {
|
||||
let _lastApply = Promise.resolve();
|
||||
let _applyFailed = false;
|
||||
function applyGlobalPromptQuiet(text) {
|
||||
const p = _applyGlobalPromptQuiet(text);
|
||||
_lastApply = p;
|
||||
return p;
|
||||
}
|
||||
|
||||
async function _applyGlobalPromptQuiet(text) {
|
||||
// Same shape as commit()'s guard above, and needed for the same reason:
|
||||
// this has an internal closure-scoped caller (applyPromptTemplate) besides its exported
|
||||
// property, so blocking Store.applyGlobalPromptQuiet from tour.js alone
|
||||
@@ -607,8 +622,7 @@
|
||||
if (window.__FUNPACK_TOUR__) return false;
|
||||
if (!state.project) return false;
|
||||
const trimmed = String(text || "").trim();
|
||||
if (!trimmed) return false;
|
||||
if (trimmed === buildGlobalPromptFromTimeline(state.project) && !_pinnedGlobalPrompt) return true;
|
||||
if (trimmed === buildGlobalPromptFromTimeline(state.project) && !_pinnedGlobalPrompt) { _applyFailed = false; return true; }
|
||||
const seq = ++_applySeq;
|
||||
_applyInFlight++;
|
||||
try {
|
||||
@@ -617,8 +631,10 @@
|
||||
try { res = await API.parsePrompt(state.project.id, trimmed); }
|
||||
catch (e) {
|
||||
console.warn("Global prompt parse failed:", e);
|
||||
_applyFailed = true;
|
||||
return false;
|
||||
}
|
||||
_applyFailed = false;
|
||||
// The user typed more while this parse was in flight: a newer apply is scheduled (or
|
||||
// already running). Distributing this OLDER text now would re-split the timeline to a
|
||||
// stale prompt and dispatch a stale updated event — drop it and let the newer one win.
|
||||
|
||||
@@ -35,6 +35,8 @@ def markers() -> list[str]:
|
||||
def _clean(raw) -> list[str]:
|
||||
out = []
|
||||
for m in raw if isinstance(raw, list) else []:
|
||||
if not isinstance(m, str):
|
||||
continue
|
||||
m = " ".join(str(m).split())[:MAX_MARKER]
|
||||
if m and m.lower() not in (o.lower() for o in out):
|
||||
out.append(m)
|
||||
|
||||
@@ -42,3 +42,9 @@ def test_empty_marker_list_is_refused_and_keeps_the_old_one():
|
||||
def test_unreadable_store_falls_back_to_default():
|
||||
config.MARKERS_FILE.write_text("{nope", encoding="utf-8")
|
||||
assert story.markers() == story.DEFAULT_MARKERS
|
||||
|
||||
|
||||
def test_non_string_markers_are_ignored():
|
||||
assert story.save_markers([None, 5, "cut"]) == ["cut"]
|
||||
with pytest.raises(ValueError):
|
||||
story.save_markers([None, 5])
|
||||
|
||||
Reference in New Issue
Block a user