Keep every retried attempt instead of deleting it
Retry used to delete the last AI action and generate a replacement, so the discarded narration was simply gone. The row survives now: each attempt is appended to actions.variants with variant_index naming the live one, and a ChatGPT-style pager under the message browses them. Action.text still mirrors the active variant, so the context builder, memory bank, summarizer and export needed no changes. A variant carries only what differs between attempts -- the text, the reasoning, and the state it produced -- never the assembled prompt, which is identical across attempts of one turn and is the bulk of context_snapshot. Only the last message can be switched, restoring the script/world state that attempt produced; earlier turns were written as a continuation of whatever is active there, so theirs are read-only previews. Three things that would otherwise bite: - generate_turn now wraps _generate_turn and watches for a save sentinel. If the generator ends without it (provider error, empty reply, script stop, client hangup) it re-applies the previous variant -- otherwise a failed retry leaves rolled-back stats under un-rolled-back text. - Retry reuses the turn's own index rather than next_index, or the clock the world-state cooldowns run on advances on a re-run of the same turn. - Editing a message rewrites the active variant too, or paging away and back silently reverts the edit. Migrations 34/35 verified as an upgrade against a populated database, not just a fresh schema. test_state_revert's retry test asserted the old delete behaviour and was rewritten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet
This commit is contained in:
co-authored by
Claude Opus 5
parent
be6dbb0f24
commit
906ba423d8
@@ -90,6 +90,14 @@ export const api = {
|
||||
request(`/adventures/${advId}/actions/${actionId}`, { method: 'PATCH', body: JSON.stringify({ text }) }),
|
||||
deleteAction: (advId, actionId) =>
|
||||
request(`/adventures/${advId}/actions/${actionId}`, { method: 'DELETE' }),
|
||||
// Retry history. The adventure payload carries only the counts, so the
|
||||
// attempts themselves are fetched when the reader actually pages through.
|
||||
listVariants: (advId, actionId) =>
|
||||
request(`/adventures/${advId}/actions/${actionId}/variants`),
|
||||
selectVariant: (advId, actionId, index) =>
|
||||
request(`/adventures/${advId}/actions/${actionId}/variant`, {
|
||||
method: 'POST', body: JSON.stringify({ index }),
|
||||
}),
|
||||
|
||||
sendAction: (advId, payload, handlers, signal) =>
|
||||
streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal),
|
||||
|
||||
@@ -392,6 +392,43 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
|
||||
}
|
||||
.story .action-tools button:hover { color: var(--text); }
|
||||
|
||||
/* Retry-history pager under an AI beat. Stays quiet until hovered — it's a
|
||||
footnote on the message, not part of the prose. */
|
||||
.variant-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
font-family: var(--font-ui);
|
||||
opacity: 0.45;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.story .action:hover .variant-pager,
|
||||
.variant-pager:focus-within { opacity: 1; }
|
||||
.variant-pager button {
|
||||
padding: 0 7px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.variant-pager button:hover:not(:disabled) { color: var(--accent-bright); border-color: var(--border); }
|
||||
.variant-pager button:disabled { opacity: 0.35; cursor: default; }
|
||||
.variant-count {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.variant-note {
|
||||
margin-left: 4px;
|
||||
font-size: 0.72rem;
|
||||
font-style: italic;
|
||||
color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.action-edit { margin-bottom: 14px; }
|
||||
/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
|
||||
several paragraphs, and the old fixed 110px turned that into a keyhole.
|
||||
|
||||
@@ -920,6 +920,59 @@ function WorldStateDrawer({ advId, refreshKey }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ChatGPT-style ‹ 2/3 › pager under an AI message that has been retried.
|
||||
//
|
||||
// The last message can actually be switched (the server restores the stats that
|
||||
// attempt produced); earlier ones are read-only, because the turns after them
|
||||
// were written as a continuation of whatever is active now. Browsing an
|
||||
// earlier one is a local preview, so `onPreview` hands the text up to the story
|
||||
// renderer rather than the pager drawing it.
|
||||
function VariantPager({ advId, action, isLast, busy, previewIndex, onPreview, onSwitched, onError }) {
|
||||
const [variants, setVariants] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const count = action.variant_count
|
||||
const current = previewIndex ?? action.variant_index
|
||||
|
||||
async function go(delta) {
|
||||
const next = current + delta
|
||||
if (next < 0 || next >= count || loading || busy) return
|
||||
setLoading(true)
|
||||
try {
|
||||
if (isLast) {
|
||||
onPreview(null)
|
||||
onSwitched(await api.selectVariant(advId, action.id, next))
|
||||
} else {
|
||||
// Fetched once per message, then cached — paging back and forth
|
||||
// shouldn't re-hit the server.
|
||||
const list = variants || await api.listVariants(advId, action.id)
|
||||
if (!variants) setVariants(list)
|
||||
onPreview(next === action.variant_index
|
||||
? null
|
||||
: { actionId: action.id, index: next, text: list[next].text, reasoning: list[next].reasoning })
|
||||
}
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="variant-pager">
|
||||
<button type="button" onClick={() => go(-1)} disabled={current === 0 || busy || loading}
|
||||
title="Previous attempt">‹</button>
|
||||
<span className="variant-count">{current + 1}/{count}</span>
|
||||
<button type="button" onClick={() => go(1)} disabled={current === count - 1 || busy || loading}
|
||||
title="Next attempt">›</button>
|
||||
{previewIndex !== null && (
|
||||
<span className="variant-note">
|
||||
earlier attempt — the story continued from {action.variant_index + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Compact chips shown under an AI message summarizing what state changed.
|
||||
function StateChangeChips({ changes }) {
|
||||
if (!changes?.length) return null
|
||||
@@ -1118,6 +1171,9 @@ export default function Play() {
|
||||
// (currently "Update from scenario"), which no action count would reflect.
|
||||
const [stateKey, setStateKey] = useState(0)
|
||||
const [inspectActionId, setInspectActionId] = useState(null)
|
||||
// Read-only browsing of an earlier attempt at a past turn (see VariantPager).
|
||||
// One at a time; null when every message is showing its active version.
|
||||
const [preview, setPreview] = useState(null)
|
||||
const storyEndRef = useRef(null)
|
||||
const abortRef = useRef(null)
|
||||
const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom
|
||||
@@ -1243,6 +1299,7 @@ export default function Play() {
|
||||
}
|
||||
|
||||
function retry() {
|
||||
setPreview(null)
|
||||
setActions((prev) =>
|
||||
prev.length && prev[prev.length - 1].type === 'ai' ? prev.slice(0, -1) : prev)
|
||||
runTurn(async (signal) => {
|
||||
@@ -1259,6 +1316,7 @@ export default function Play() {
|
||||
|
||||
async function undo() {
|
||||
setToast(null)
|
||||
setPreview(null)
|
||||
try {
|
||||
setActions(await api.undo(id))
|
||||
} catch (err) {
|
||||
@@ -1348,6 +1406,9 @@ export default function Play() {
|
||||
// Drop cap goes on the first narrated beat only. `firstNarrationId`
|
||||
// is derived once above rather than per row.
|
||||
const opening = action.id === firstNarrationId
|
||||
// Non-null while the reader is browsing an older attempt of this
|
||||
// message without making it active (earlier turns only).
|
||||
const previewing = preview?.actionId === action.id ? preview : null
|
||||
|
||||
return editing?.id === action.id ? (
|
||||
<div key={action.id} className="action-edit">
|
||||
@@ -1365,9 +1426,27 @@ export default function Play() {
|
||||
<Fragment key={action.id}>
|
||||
{sceneBreak && <div className="scene-break" aria-hidden="true">❖</div>}
|
||||
<div className={`action ${isPlayer ? 'player' : ''}${opening ? ' opening' : ''}`}>
|
||||
<ReasoningBlock text={action.reasoning} />
|
||||
{renderEmphasis(action.text)}
|
||||
{action.type === 'ai' && <StateChangeChips changes={action.world_changes} />}
|
||||
<ReasoningBlock text={previewing ? previewing.reasoning : action.reasoning} />
|
||||
{renderEmphasis(previewing ? previewing.text : action.text)}
|
||||
{/* The chips describe the *active* attempt's state changes,
|
||||
which a previewed one didn't make — so they're hidden
|
||||
rather than shown against the wrong text. */}
|
||||
{action.type === 'ai' && !previewing && (
|
||||
<StateChangeChips changes={action.world_changes} />
|
||||
)}
|
||||
{action.type === 'ai' && action.variant_count > 1 && (
|
||||
<VariantPager
|
||||
advId={id}
|
||||
action={action}
|
||||
isLast={i === actions.length - 1}
|
||||
busy={busy}
|
||||
previewIndex={previewing ? previewing.index : null}
|
||||
onPreview={setPreview}
|
||||
onSwitched={(updated) =>
|
||||
setActions((prev) => prev.map((a) => (a.id === updated.id ? updated : a)))}
|
||||
onError={(message) => setToast({ text: message, isError: true })}
|
||||
/>
|
||||
)}
|
||||
{!busy && (
|
||||
<span className="action-tools">
|
||||
{action.type === 'ai' && (
|
||||
|
||||
Reference in New Issue
Block a user