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
@@ -104,6 +104,12 @@ MIGRATIONS: list[tuple[int, str]] = [
|
|||||||
# cards and leave player-authored ones alone. NULL = player-authored, or a
|
# cards and leave player-authored ones alone. NULL = player-authored, or a
|
||||||
# copy predating this column (matched by name once, then adopted).
|
# copy predating this column (matched by name once, then adopted).
|
||||||
(33, "ALTER TABLE story_cards ADD COLUMN source_ref VARCHAR(64)"),
|
(33, "ALTER TABLE story_cards ADD COLUMN source_ref VARCHAR(64)"),
|
||||||
|
# Retry history: every attempt made for an AI turn, oldest first, so retry
|
||||||
|
# can append instead of deleting. NULL = never retried (the row is its own
|
||||||
|
# only version), which is also the correct reading for every action that
|
||||||
|
# predates this column.
|
||||||
|
(34, "ALTER TABLE actions ADD COLUMN variants JSON"),
|
||||||
|
(35, "ALTER TABLE actions ADD COLUMN variant_index INTEGER NOT NULL DEFAULT 0"),
|
||||||
]
|
]
|
||||||
|
|
||||||
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
|
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
|
||||||
|
|||||||
@@ -212,6 +212,13 @@ class Action(Base):
|
|||||||
state_before: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
state_before: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
# Phase 12: same idea for the RPG world_state, so undo/retry rolls it back too.
|
# Phase 12: same idea for the RPG world_state, so undo/retry rolls it back too.
|
||||||
world_state_before: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
world_state_before: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
# Retry history (AI actions): every attempt made for this turn, oldest
|
||||||
|
# first, INCLUDING the active one. NULL/empty means never retried — the row
|
||||||
|
# is its own only version. `variant_index` says which entry `text`,
|
||||||
|
# `reasoning` and `context_snapshot` currently mirror; retry appends and
|
||||||
|
# points here instead of deleting the row, so nothing is lost.
|
||||||
|
variants: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||||
|
variant_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||||
|
|
||||||
adventure: Mapped[Adventure] = relationship(back_populates="actions")
|
adventure: Mapped[Adventure] = relationship(back_populates="actions")
|
||||||
@@ -241,6 +248,12 @@ class Action(Base):
|
|||||||
out.append({"kind": "stat", "label": label, "delta": delta, "value": new})
|
out.append({"kind": "stat", "label": label, "delta": delta, "value": new})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
@property
|
||||||
|
def variant_count(self) -> int:
|
||||||
|
"""How many attempts exist for this turn. 0 (not 1) when the action was
|
||||||
|
never retried — the UI shows its pager only above 1 either way."""
|
||||||
|
return len(self.variants) if isinstance(self.variants, list) else 0
|
||||||
|
|
||||||
|
|
||||||
class Script(Base):
|
class Script(Base):
|
||||||
__tablename__ = "scripts"
|
__tablename__ = "scripts"
|
||||||
|
|||||||
@@ -319,6 +319,63 @@ def snapshot_world_state(adventure: models.Adventure) -> dict:
|
|||||||
return copy.deepcopy(state)
|
return copy.deepcopy(state)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Retry history (variants) ----------
|
||||||
|
#
|
||||||
|
# Retry used to delete the AI action and generate a replacement. Now the row
|
||||||
|
# survives and every attempt is appended to `Action.variants`, with
|
||||||
|
# `variant_index` naming the live one. A variant carries only the parts that
|
||||||
|
# actually differ between attempts — the narration and the state it produced —
|
||||||
|
# never the assembled prompt, which is identical across attempts of one turn
|
||||||
|
# and is by far the biggest thing in `context_snapshot`.
|
||||||
|
|
||||||
|
# The per-attempt slices of context_snapshot. Everything else in the snapshot
|
||||||
|
# (system/story/memories) is shared by every attempt at the same turn.
|
||||||
|
VARIANT_SNAPSHOT_KEYS = ("world_state", "script", "raw_output")
|
||||||
|
|
||||||
|
|
||||||
|
def variant_of(action: models.Action, adventure: models.Adventure) -> dict:
|
||||||
|
"""Freeze an action's *current* content as a variant entry.
|
||||||
|
|
||||||
|
`adventure` supplies the resulting script/world state, so this must be
|
||||||
|
called before any rollback — those live values are this attempt's outcome.
|
||||||
|
"""
|
||||||
|
snapshot = action.context_snapshot if isinstance(action.context_snapshot, dict) else {}
|
||||||
|
entry = {
|
||||||
|
"text": action.text,
|
||||||
|
"reasoning": action.reasoning,
|
||||||
|
"script_state": snapshot_state(adventure),
|
||||||
|
"created_at": action.created_at.isoformat() if action.created_at else None,
|
||||||
|
}
|
||||||
|
for key in VARIANT_SNAPSHOT_KEYS:
|
||||||
|
if key in snapshot:
|
||||||
|
entry[key] = copy.deepcopy(snapshot[key])
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def apply_variant(action: models.Action, adventure: models.Adventure, index: int) -> None:
|
||||||
|
"""Make variant `index` the live one: its text onto the action, its
|
||||||
|
outcome back onto the adventure."""
|
||||||
|
entry = action.variants[index]
|
||||||
|
action.text = entry.get("text", "")
|
||||||
|
action.reasoning = entry.get("reasoning")
|
||||||
|
snapshot = dict(action.context_snapshot) if isinstance(action.context_snapshot, dict) else {}
|
||||||
|
for key in VARIANT_SNAPSHOT_KEYS:
|
||||||
|
if key in entry:
|
||||||
|
snapshot[key] = copy.deepcopy(entry[key])
|
||||||
|
else:
|
||||||
|
snapshot.pop(key, None)
|
||||||
|
action.context_snapshot = snapshot
|
||||||
|
action.variant_index = index
|
||||||
|
if isinstance(entry.get("script_state"), dict):
|
||||||
|
adventure.script_state = copy.deepcopy(entry["script_state"])
|
||||||
|
# The world state this attempt left behind lives inside its own snapshot
|
||||||
|
# slice; absent for adventures with no RPG layer, where there's nothing to
|
||||||
|
# restore anyway.
|
||||||
|
world_state = (entry.get("world_state") or {}).get("state")
|
||||||
|
if isinstance(world_state, dict):
|
||||||
|
adventure.world_state = copy.deepcopy(world_state)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{adventure_id}", response_model=schemas.AdventureOut)
|
@router.patch("/{adventure_id}", response_model=schemas.AdventureOut)
|
||||||
def update_adventure(
|
def update_adventure(
|
||||||
adventure_id: int,
|
adventure_id: int,
|
||||||
@@ -409,9 +466,44 @@ async def generate_turn(
|
|||||||
db: Session,
|
db: Session,
|
||||||
pipeline: ScriptPipeline,
|
pipeline: ScriptPipeline,
|
||||||
user: models.User,
|
user: models.User,
|
||||||
|
retry_of: models.Action | None = None,
|
||||||
):
|
):
|
||||||
"""SSE generator: streams the AI continuation through the context/output
|
"""SSE generator: streams the AI continuation through the context/output
|
||||||
script hooks, then stores the result."""
|
script hooks, then stores the result.
|
||||||
|
|
||||||
|
With `retry_of`, the result is appended as a new variant of that existing
|
||||||
|
AI action rather than stored as a new one — the discarded attempt stays
|
||||||
|
readable. The caller must have seeded `retry_of.variants` and rolled the
|
||||||
|
adventure back first (see `retry_action`); if this generator ends without
|
||||||
|
saving, that rollback is undone so state can't drift from the text still
|
||||||
|
on screen."""
|
||||||
|
saved = False
|
||||||
|
try:
|
||||||
|
async for event in _generate_turn(adventure, db, pipeline, user, retry_of):
|
||||||
|
if event is _SAVED:
|
||||||
|
saved = True
|
||||||
|
continue
|
||||||
|
yield event
|
||||||
|
finally:
|
||||||
|
if retry_of is not None and not saved:
|
||||||
|
# Provider error, empty reply, a script stop, or the client hanging
|
||||||
|
# up: put the attempt we rolled away from back in charge.
|
||||||
|
apply_variant(retry_of, adventure, retry_of.variant_index)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# Sentinel yielded by _generate_turn once the action is committed, so the
|
||||||
|
# wrapper above knows the rollback must stand rather than be reversed.
|
||||||
|
_SAVED = object()
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_turn(
|
||||||
|
adventure: models.Adventure,
|
||||||
|
db: Session,
|
||||||
|
pipeline: ScriptPipeline,
|
||||||
|
user: models.User,
|
||||||
|
retry_of: models.Action | None = None,
|
||||||
|
):
|
||||||
settings = get_settings(db, user)
|
settings = get_settings(db, user)
|
||||||
cfg = auth.resolve_provider_config(settings)
|
cfg = auth.resolve_provider_config(settings)
|
||||||
if cfg.using_demo:
|
if cfg.using_demo:
|
||||||
@@ -495,7 +587,9 @@ async def generate_turn(
|
|||||||
|
|
||||||
# RPG world state (Phase 12): pull the AI's state delta out of the reply,
|
# RPG world state (Phase 12): pull the AI's state delta out of the reply,
|
||||||
# let the engine referee it, and strip the block from the shown text.
|
# let the engine referee it, and strip the block from the shown text.
|
||||||
ai_index = next_index(adventure)
|
# A retry re-runs the *same* turn, so it keeps that turn's index — using
|
||||||
|
# next_index here would advance the clock the cooldown rules run on.
|
||||||
|
ai_index = retry_of.index if retry_of is not None else next_index(adventure)
|
||||||
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
|
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
|
||||||
if worldstate.has_schema(stat_schema):
|
if worldstate.has_schema(stat_schema):
|
||||||
text, delta = worldstate.extract_delta(text)
|
text, delta = worldstate.extract_delta(text)
|
||||||
@@ -510,12 +604,29 @@ async def generate_turn(
|
|||||||
|
|
||||||
snapshot["raw_output"] = raw_output
|
snapshot["raw_output"] = raw_output
|
||||||
|
|
||||||
|
reasoning = "".join(reasoning_chunks).strip() or None
|
||||||
|
if retry_of is not None:
|
||||||
|
# Same row, one more attempt. Writing text/reasoning/snapshot through
|
||||||
|
# the variant list keeps the row and its history in step.
|
||||||
|
ai_action = retry_of
|
||||||
|
ai_action.context_snapshot = snapshot
|
||||||
|
history = list(ai_action.variants or [])
|
||||||
|
history.append({
|
||||||
|
"text": text,
|
||||||
|
"reasoning": reasoning,
|
||||||
|
"script_state": snapshot_state(adventure),
|
||||||
|
"created_at": models.utcnow().isoformat(),
|
||||||
|
**{k: copy.deepcopy(snapshot[k]) for k in VARIANT_SNAPSHOT_KEYS if k in snapshot},
|
||||||
|
})
|
||||||
|
ai_action.variants = history
|
||||||
|
apply_variant(ai_action, adventure, len(history) - 1)
|
||||||
|
else:
|
||||||
ai_action = models.Action(
|
ai_action = models.Action(
|
||||||
adventure_id=adventure.id,
|
adventure_id=adventure.id,
|
||||||
index=ai_index,
|
index=ai_index,
|
||||||
type="ai",
|
type="ai",
|
||||||
text=text,
|
text=text,
|
||||||
reasoning="".join(reasoning_chunks).strip() or None,
|
reasoning=reasoning,
|
||||||
context_snapshot=snapshot,
|
context_snapshot=snapshot,
|
||||||
state_before=state_before,
|
state_before=state_before,
|
||||||
world_state_before=world_state_before,
|
world_state_before=world_state_before,
|
||||||
@@ -528,6 +639,7 @@ async def generate_turn(
|
|||||||
auth.count_demo_turn(user)
|
auth.count_demo_turn(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(ai_action)
|
db.refresh(ai_action)
|
||||||
|
yield _SAVED
|
||||||
yield sse({"type": "done", "action": action_json(ai_action), "script": pipeline.report()})
|
yield sse({"type": "done", "action": action_json(ai_action), "script": pipeline.report()})
|
||||||
# Phase 6: fire-and-forget summarization/embedding (opens its own DB
|
# Phase 6: fire-and-forget summarization/embedding (opens its own DB
|
||||||
# session). Skipped on the demo key — background AI calls would be
|
# session). Skipped on the demo key — background AI calls would be
|
||||||
@@ -617,14 +729,26 @@ def retry_action(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: models.User = CurrentUser,
|
user: models.User = CurrentUser,
|
||||||
):
|
):
|
||||||
"""Delete the last AI action and regenerate from the same input."""
|
"""Regenerate the last AI action, keeping the discarded attempt.
|
||||||
|
|
||||||
|
The row survives: its current content is frozen as a variant, the shared
|
||||||
|
script/world state rolls back to the pre-turn snapshot, and the new attempt
|
||||||
|
is appended as the next variant. Nothing the AI wrote is ever thrown away.
|
||||||
|
"""
|
||||||
adventure = get_adventure_or_404(adventure_id, db, user)
|
adventure = get_adventure_or_404(adventure_id, db, user)
|
||||||
limits.rate_limit("turn", request, user)
|
limits.rate_limit("turn", request, user)
|
||||||
check_demo_cap(db, user)
|
check_demo_cap(db, user)
|
||||||
acquire_turn_lock(adventure_id)
|
acquire_turn_lock(adventure_id)
|
||||||
|
last_ai = None
|
||||||
try:
|
try:
|
||||||
if adventure.actions and adventure.actions[-1].type == "ai":
|
if adventure.actions and adventure.actions[-1].type == "ai":
|
||||||
last_ai = adventure.actions[-1]
|
last_ai = adventure.actions[-1]
|
||||||
|
# First retry: the row has no history yet, so record what's on
|
||||||
|
# screen as variant 0 before anything is rolled back — the live
|
||||||
|
# script/world state is precisely that attempt's outcome.
|
||||||
|
if not last_ai.variants:
|
||||||
|
last_ai.variants = [variant_of(last_ai, adventure)]
|
||||||
|
last_ai.variant_index = 0
|
||||||
# Roll the scoreboard back to before this AI turn's hooks ran, so
|
# Roll the scoreboard back to before this AI turn's hooks ran, so
|
||||||
# regenerating starts fresh instead of stacking output mutations on
|
# regenerating starts fresh instead of stacking output mutations on
|
||||||
# top of the discarded attempt. NULL for pre-migration actions.
|
# top of the discarded attempt. NULL for pre-migration actions.
|
||||||
@@ -632,7 +756,6 @@ def retry_action(
|
|||||||
adventure.script_state = copy.deepcopy(last_ai.state_before)
|
adventure.script_state = copy.deepcopy(last_ai.state_before)
|
||||||
if last_ai.world_state_before is not None:
|
if last_ai.world_state_before is not None:
|
||||||
adventure.world_state = copy.deepcopy(last_ai.world_state_before)
|
adventure.world_state = copy.deepcopy(last_ai.world_state_before)
|
||||||
db.delete(last_ai)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(adventure)
|
db.refresh(adventure)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
@@ -641,13 +764,87 @@ def retry_action(
|
|||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
with_turn_lock(
|
with_turn_lock(
|
||||||
adventure_id,
|
adventure_id,
|
||||||
generate_turn(adventure, db, ScriptPipeline(adventure, db), user),
|
generate_turn(
|
||||||
|
adventure, db, ScriptPipeline(adventure, db), user, retry_of=last_ai
|
||||||
|
),
|
||||||
),
|
),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
headers=SSE_HEADERS,
|
headers=SSE_HEADERS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{adventure_id}/actions/{action_id}/variants",
|
||||||
|
response_model=list[schemas.VariantOut],
|
||||||
|
)
|
||||||
|
def list_variants(
|
||||||
|
adventure_id: int,
|
||||||
|
action_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: models.User = CurrentUser,
|
||||||
|
):
|
||||||
|
"""Every attempt made for one AI turn. Fetched on demand — the adventure
|
||||||
|
payload carries only the counts, so old narration doesn't ride along on
|
||||||
|
every page load."""
|
||||||
|
get_adventure_or_404(adventure_id, db, user)
|
||||||
|
action = db.get(models.Action, action_id)
|
||||||
|
if action is None or action.adventure_id != adventure_id:
|
||||||
|
raise HTTPException(404, "Action not found")
|
||||||
|
variants = action.variants if isinstance(action.variants, list) else []
|
||||||
|
return [
|
||||||
|
schemas.VariantOut(
|
||||||
|
index=i,
|
||||||
|
text=entry.get("text", ""),
|
||||||
|
reasoning=entry.get("reasoning"),
|
||||||
|
created_at=entry.get("created_at"),
|
||||||
|
active=(i == action.variant_index),
|
||||||
|
)
|
||||||
|
for i, entry in enumerate(variants)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{adventure_id}/actions/{action_id}/variant", response_model=schemas.ActionOut
|
||||||
|
)
|
||||||
|
def select_variant(
|
||||||
|
adventure_id: int,
|
||||||
|
action_id: int,
|
||||||
|
payload: schemas.VariantSelect,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: models.User = CurrentUser,
|
||||||
|
):
|
||||||
|
"""Make an earlier attempt the live one again, restoring the script/world
|
||||||
|
state it produced.
|
||||||
|
|
||||||
|
Only the last action can be switched: the turns after an older one were
|
||||||
|
written as a continuation of the text that's currently active, so swapping
|
||||||
|
it out from underneath them would leave the story contradicting itself.
|
||||||
|
Earlier turns' attempts stay readable through `list_variants`.
|
||||||
|
"""
|
||||||
|
adventure = get_adventure_or_404(adventure_id, db, user)
|
||||||
|
action = db.get(models.Action, action_id)
|
||||||
|
if action is None or action.adventure_id != adventure_id:
|
||||||
|
raise HTTPException(404, "Action not found")
|
||||||
|
variants = action.variants if isinstance(action.variants, list) else []
|
||||||
|
if not 0 <= payload.index < len(variants):
|
||||||
|
raise HTTPException(400, "No such attempt for this action")
|
||||||
|
if not adventure.actions or adventure.actions[-1].id != action.id:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
"Only the latest message can be switched — the story has already "
|
||||||
|
"continued from this one.",
|
||||||
|
)
|
||||||
|
acquire_turn_lock(adventure_id)
|
||||||
|
try:
|
||||||
|
apply_variant(action, adventure, payload.index)
|
||||||
|
adventure.updated_at = models.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(action)
|
||||||
|
return action
|
||||||
|
finally:
|
||||||
|
_active_turns.discard(adventure_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{adventure_id}/undo", response_model=list[schemas.ActionOut])
|
@router.post("/{adventure_id}/undo", response_model=list[schemas.ActionOut])
|
||||||
def undo_turn(
|
def undo_turn(
|
||||||
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
|
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
|
||||||
@@ -730,6 +927,15 @@ def export_adventure(
|
|||||||
{
|
{
|
||||||
"index": a.index, "type": a.type, "text": a.text,
|
"index": a.index, "type": a.type, "text": a.text,
|
||||||
"reasoning": a.reasoning,
|
"reasoning": a.reasoning,
|
||||||
|
# Retry history, narration only — a bundle carries no context
|
||||||
|
# snapshots, so the per-attempt script/world state it would
|
||||||
|
# restore isn't there to export either.
|
||||||
|
"variants": [
|
||||||
|
{"text": v.get("text", ""), "reasoning": v.get("reasoning"),
|
||||||
|
"createdAt": v.get("created_at")}
|
||||||
|
for v in (a.variants or [])
|
||||||
|
] or None,
|
||||||
|
"variantIndex": a.variant_index,
|
||||||
"createdAt": a.created_at.isoformat(),
|
"createdAt": a.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
for a in adv.actions
|
for a in adv.actions
|
||||||
@@ -812,12 +1018,22 @@ def import_adventure(
|
|||||||
|
|
||||||
for i, a in enumerate(bundle.get("actions") or []):
|
for i, a in enumerate(bundle.get("actions") or []):
|
||||||
if isinstance(a, dict) and str(a.get("text") or ""):
|
if isinstance(a, dict) and str(a.get("text") or ""):
|
||||||
|
variants = [
|
||||||
|
{"text": str(v.get("text") or ""), "reasoning": v.get("reasoning"),
|
||||||
|
"created_at": v.get("createdAt")}
|
||||||
|
for v in (a.get("variants") or [])
|
||||||
|
if isinstance(v, dict)
|
||||||
|
]
|
||||||
db.add(models.Action(
|
db.add(models.Action(
|
||||||
adventure_id=adventure.id,
|
adventure_id=adventure.id,
|
||||||
index=int(a.get("index", i)),
|
index=int(a.get("index", i)),
|
||||||
type=str(a.get("type") or "story")[:20], # VARCHAR(20)
|
type=str(a.get("type") or "story")[:20], # VARCHAR(20)
|
||||||
text=str(a["text"]),
|
text=str(a["text"]),
|
||||||
reasoning=str(a["reasoning"]) if a.get("reasoning") else None,
|
reasoning=str(a["reasoning"]) if a.get("reasoning") else None,
|
||||||
|
variants=variants or None,
|
||||||
|
# Clamped: a bundle could name an index its variant list
|
||||||
|
# doesn't have, which would make the pager point at nothing.
|
||||||
|
variant_index=min(max(int(a.get("variantIndex", 0)), 0), max(len(variants) - 1, 0)),
|
||||||
))
|
))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -1246,6 +1462,13 @@ def update_action(
|
|||||||
if action is None or action.adventure_id != adventure_id:
|
if action is None or action.adventure_id != adventure_id:
|
||||||
raise HTTPException(404, "Action not found")
|
raise HTTPException(404, "Action not found")
|
||||||
action.text = payload.text
|
action.text = payload.text
|
||||||
|
# Keep the live variant in step, or paging away and back would silently
|
||||||
|
# revert the edit.
|
||||||
|
variants = action.variants if isinstance(action.variants, list) else []
|
||||||
|
if 0 <= action.variant_index < len(variants):
|
||||||
|
history = copy.deepcopy(variants)
|
||||||
|
history[action.variant_index]["text"] = payload.text
|
||||||
|
action.variants = history
|
||||||
db.commit()
|
db.commit()
|
||||||
return action
|
return action
|
||||||
|
|
||||||
|
|||||||
@@ -183,9 +183,26 @@ class ActionOut(ORMModel):
|
|||||||
reasoning: str | None = None
|
reasoning: str | None = None
|
||||||
# Phase 12: compact RPG state changes for this turn (from the model property).
|
# Phase 12: compact RPG state changes for this turn (from the model property).
|
||||||
world_changes: list[dict] = []
|
world_changes: list[dict] = []
|
||||||
|
# Retry history: how many attempts exist for this turn (0 = never retried)
|
||||||
|
# and which one is live. The attempts themselves come from
|
||||||
|
# GET /actions/{id}/variants so this payload stays small.
|
||||||
|
variant_count: int = 0
|
||||||
|
variant_index: int = 0
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class VariantOut(BaseModel):
|
||||||
|
index: int
|
||||||
|
text: str
|
||||||
|
reasoning: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
active: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class VariantSelect(BaseModel):
|
||||||
|
index: int = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
class ActionUpdate(BaseModel):
|
class ActionUpdate(BaseModel):
|
||||||
text: ActionText
|
text: ActionText
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""End-to-end HTTP tests for retry history: every attempt at an AI turn is kept
|
||||||
|
as a variant of the same action, browsable and (for the last message)
|
||||||
|
switchable, restoring the world/script state that attempt produced.
|
||||||
|
|
||||||
|
python -m pytest tests/test_retry_variants.py -v
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
_tmp.close()
|
||||||
|
os.environ["AIDND_DB_PATH"] = _tmp.name
|
||||||
|
os.environ.pop("AIDND_DATABASE_URL", None)
|
||||||
|
os.environ.pop("DATABASE_URL", None)
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import auth, limits, models
|
||||||
|
from app.database import Base, SessionLocal, engine, get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.providers import PromptParts, ProviderError
|
||||||
|
from app.routers import adventures
|
||||||
|
|
||||||
|
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
|
||||||
|
|
||||||
|
# Each turn spends 10 gold, so a double-applied or un-rolled-back attempt shows.
|
||||||
|
GOLD_SCRIPT = """
|
||||||
|
const modifier = (text) => {
|
||||||
|
state.gold = (state.gold || 0) + 10;
|
||||||
|
return { text };
|
||||||
|
};
|
||||||
|
modifier(text);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptedProvider:
|
||||||
|
"""Streams the next canned reply each call, so successive retries differ."""
|
||||||
|
replies: list = []
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def generate(self, parts: PromptParts, *, temperature, max_tokens):
|
||||||
|
index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1)
|
||||||
|
ScriptedProvider.calls += 1
|
||||||
|
reply = ScriptedProvider.replies[index]
|
||||||
|
if isinstance(reply, Exception):
|
||||||
|
raise reply
|
||||||
|
yield ("text", reply)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
setup = SessionLocal()
|
||||||
|
user = models.User(is_guest=False, email="variants@example.com")
|
||||||
|
setup.add(user)
|
||||||
|
setup.flush()
|
||||||
|
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
|
||||||
|
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=SCHEMA)
|
||||||
|
setup.add(scenario)
|
||||||
|
setup.flush()
|
||||||
|
adv = models.Adventure(
|
||||||
|
user_id=user.id, title="Cave", scenario_id=scenario.id,
|
||||||
|
script_state={}, world_state={"player": {"hp": 100}},
|
||||||
|
)
|
||||||
|
setup.add(adv)
|
||||||
|
setup.flush()
|
||||||
|
setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You enter a cave."))
|
||||||
|
setup.add(models.AdventureScript(
|
||||||
|
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
|
||||||
|
))
|
||||||
|
setup.commit()
|
||||||
|
adv_id, user_id = adv.id, user.id
|
||||||
|
setup.close()
|
||||||
|
|
||||||
|
ScriptedProvider.replies = ["Attempt one."]
|
||||||
|
ScriptedProvider.calls = 0
|
||||||
|
monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider)
|
||||||
|
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
||||||
|
"http://fake", "k", "test-model", False))
|
||||||
|
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||||
|
|
||||||
|
def _current_user(db=Depends(get_db)):
|
||||||
|
return db.get(models.User, user_id)
|
||||||
|
|
||||||
|
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||||
|
c = TestClient(app)
|
||||||
|
c.adv_id = adv_id
|
||||||
|
try:
|
||||||
|
yield c
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
adventures._active_turns.clear()
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
def _adv(adv_id):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
adv = db.get(models.Adventure, adv_id)
|
||||||
|
return adv.script_state, adv.world_state
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _actions(client):
|
||||||
|
return client.get(f"/api/adventures/{client.adv_id}").json()["actions"]
|
||||||
|
|
||||||
|
|
||||||
|
def _play(client, text="look around"):
|
||||||
|
r = client.post(f"/api/adventures/{client.adv_id}/actions",
|
||||||
|
json={"type": "do", "text": text})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def _retry(client):
|
||||||
|
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- keeping them
|
||||||
|
|
||||||
|
def test_retry_keeps_the_discarded_attempt(client):
|
||||||
|
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
|
||||||
|
_play(client)
|
||||||
|
assert _actions(client)[-1]["text"] == "Attempt one."
|
||||||
|
|
||||||
|
_retry(client)
|
||||||
|
actions = _actions(client)
|
||||||
|
# One AI action still, not two — the retry replaced the live text in place.
|
||||||
|
assert [a["type"] for a in actions] == ["start", "do", "ai"]
|
||||||
|
last = actions[-1]
|
||||||
|
assert last["text"] == "Attempt two."
|
||||||
|
assert last["variant_count"] == 2
|
||||||
|
assert last["variant_index"] == 1
|
||||||
|
|
||||||
|
r = client.get(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variants")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert [v["text"] for v in r.json()] == ["Attempt one.", "Attempt two."]
|
||||||
|
assert [v["active"] for v in r.json()] == [False, True]
|
||||||
|
|
||||||
|
|
||||||
|
def test_never_retried_action_has_no_variants(client):
|
||||||
|
_play(client)
|
||||||
|
last = _actions(client)[-1]
|
||||||
|
assert last["variant_count"] == 0
|
||||||
|
assert client.get(
|
||||||
|
f"/api/adventures/{client.adv_id}/actions/{last['id']}/variants").json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_attempts_all_kept_in_order(client):
|
||||||
|
ScriptedProvider.replies = ["One.", "Two.", "Three."]
|
||||||
|
_play(client)
|
||||||
|
_retry(client)
|
||||||
|
_retry(client)
|
||||||
|
last = _actions(client)[-1]
|
||||||
|
assert last["variant_count"] == 3
|
||||||
|
assert last["variant_index"] == 2
|
||||||
|
variants = client.get(
|
||||||
|
f"/api/adventures/{client.adv_id}/actions/{last['id']}/variants").json()
|
||||||
|
assert [v["text"] for v in variants] == ["One.", "Two.", "Three."]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- switching
|
||||||
|
|
||||||
|
def test_switching_back_restores_that_attempt_state(client):
|
||||||
|
ScriptedProvider.replies = [
|
||||||
|
"You take a scratch.\n```state\n{\"player.hp\": -5}\n```",
|
||||||
|
"You take a beating.\n```state\n{\"player.hp\": -40}\n```",
|
||||||
|
]
|
||||||
|
_play(client)
|
||||||
|
assert _adv(client.adv_id)[1]["player"]["hp"] == 95
|
||||||
|
_retry(client)
|
||||||
|
script_state, world_state = _adv(client.adv_id)
|
||||||
|
assert world_state["player"]["hp"] == 60
|
||||||
|
assert script_state == {"gold": 10} # rolled back, not stacked to 20
|
||||||
|
|
||||||
|
last = _actions(client)[-1]
|
||||||
|
r = client.post(
|
||||||
|
f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant", json={"index": 0})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["text"].startswith("You take a scratch")
|
||||||
|
assert r.json()["variant_index"] == 0
|
||||||
|
# The stats follow the narration back.
|
||||||
|
script_state, world_state = _adv(client.adv_id)
|
||||||
|
assert world_state["player"]["hp"] == 95
|
||||||
|
assert script_state == {"gold": 10}
|
||||||
|
|
||||||
|
# And forward again.
|
||||||
|
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
|
||||||
|
json={"index": 1})
|
||||||
|
assert _adv(client.adv_id)[1]["player"]["hp"] == 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_updates_the_world_change_chips(client):
|
||||||
|
ScriptedProvider.replies = [
|
||||||
|
"A scratch.\n```state\n{\"player.hp\": -5}\n```",
|
||||||
|
"A beating.\n```state\n{\"player.hp\": -40}\n```",
|
||||||
|
]
|
||||||
|
_play(client)
|
||||||
|
_retry(client)
|
||||||
|
last = _actions(client)[-1]
|
||||||
|
assert last["world_changes"][0]["delta"] == -40
|
||||||
|
|
||||||
|
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
|
||||||
|
json={"index": 0})
|
||||||
|
assert _actions(client)[-1]["world_changes"][0]["delta"] == -5
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_switch_a_turn_the_story_moved_past(client):
|
||||||
|
ScriptedProvider.replies = ["One.", "Two.", "Three."]
|
||||||
|
_play(client)
|
||||||
|
_retry(client)
|
||||||
|
retried = _actions(client)[-1]
|
||||||
|
_play(client) # story continues from "Two."
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/api/adventures/{client.adv_id}/actions/{retried['id']}/variant", json={"index": 0})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "latest message" in r.json()["detail"]
|
||||||
|
# Still readable, though — that's the whole point of keeping them.
|
||||||
|
variants = client.get(
|
||||||
|
f"/api/adventures/{client.adv_id}/actions/{retried['id']}/variants").json()
|
||||||
|
assert [v["text"] for v in variants] == ["One.", "Two."]
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_to_a_missing_index_is_rejected(client):
|
||||||
|
ScriptedProvider.replies = ["One.", "Two."]
|
||||||
|
_play(client)
|
||||||
|
_retry(client)
|
||||||
|
last = _actions(client)[-1]
|
||||||
|
r = client.post(
|
||||||
|
f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant", json={"index": 7})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- edge cases
|
||||||
|
|
||||||
|
def test_failed_retry_leaves_the_previous_attempt_in_charge(client):
|
||||||
|
"""A provider error mid-retry must undo the rollback, or the stats on
|
||||||
|
screen would silently disagree with the text still shown."""
|
||||||
|
ScriptedProvider.replies = ["Attempt one.", ProviderError("upstream is down")]
|
||||||
|
_play(client)
|
||||||
|
assert _adv(client.adv_id)[0] == {"gold": 10}
|
||||||
|
|
||||||
|
client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||||
|
|
||||||
|
actions = _actions(client)
|
||||||
|
assert actions[-1]["text"] == "Attempt one." # text never lost
|
||||||
|
assert _adv(client.adv_id)[0] == {"gold": 10} # and the state still matches it
|
||||||
|
|
||||||
|
|
||||||
|
def test_undo_removes_the_action_and_its_history(client):
|
||||||
|
ScriptedProvider.replies = ["One.", "Two."]
|
||||||
|
_play(client)
|
||||||
|
_retry(client)
|
||||||
|
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert [a["type"] for a in r.json()] == ["start"]
|
||||||
|
assert _adv(client.adv_id)[0] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_editing_the_text_updates_the_live_variant(client):
|
||||||
|
ScriptedProvider.replies = ["One.", "Two."]
|
||||||
|
_play(client)
|
||||||
|
_retry(client)
|
||||||
|
last = _actions(client)[-1]
|
||||||
|
client.patch(f"/api/adventures/{client.adv_id}/actions/{last['id']}",
|
||||||
|
json={"text": "Two, but better."})
|
||||||
|
|
||||||
|
# Page away and back: the edit must survive, not be reverted by the switch.
|
||||||
|
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
|
||||||
|
json={"index": 0})
|
||||||
|
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
|
||||||
|
json={"index": 1})
|
||||||
|
assert _actions(client)[-1]["text"] == "Two, but better."
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_keeps_the_turn_index(client):
|
||||||
|
"""A retry re-runs the same turn, so the world-state clock (which drives
|
||||||
|
cooldowns) must not advance."""
|
||||||
|
ScriptedProvider.replies = ["One.", "Two."]
|
||||||
|
_play(client)
|
||||||
|
before = _actions(client)[-1]["index"]
|
||||||
|
_retry(client)
|
||||||
|
assert _actions(client)[-1]["index"] == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_and_import_round_trips_variants(client):
|
||||||
|
ScriptedProvider.replies = ["One.", "Two."]
|
||||||
|
_play(client)
|
||||||
|
_retry(client)
|
||||||
|
bundle = client.get(f"/api/adventures/{client.adv_id}/export").json()
|
||||||
|
ai = [a for a in bundle["actions"] if a["type"] == "ai"][0]
|
||||||
|
assert [v["text"] for v in ai["variants"]] == ["One.", "Two."]
|
||||||
|
assert ai["variantIndex"] == 1
|
||||||
|
|
||||||
|
r = client.post("/api/adventures/import", json=bundle)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
imported = r.json()["id"]
|
||||||
|
actions = client.get(f"/api/adventures/{imported}").json()["actions"]
|
||||||
|
assert actions[-1]["variant_count"] == 2
|
||||||
|
assert actions[-1]["variant_index"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_clamps_an_out_of_range_variant_index(client):
|
||||||
|
bundle = {
|
||||||
|
"format": "ai-dnd-adventure-v1", "title": "Hand-edited",
|
||||||
|
"actions": [{
|
||||||
|
"index": 0, "type": "ai", "text": "Only.",
|
||||||
|
"variants": [{"text": "Only."}], "variantIndex": 9,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
r = client.post("/api/adventures/import", json=bundle)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
actions = client.get(f"/api/adventures/{r.json()['id']}").json()["actions"]
|
||||||
|
assert actions[0]["variant_index"] == 0
|
||||||
@@ -178,8 +178,8 @@ def test_snapshot_state_handles_non_dict(db):
|
|||||||
# ---------------------------------------------------------------- retry
|
# ---------------------------------------------------------------- retry
|
||||||
|
|
||||||
def test_retry_restores_state_before_regenerating(db, monkeypatch):
|
def test_retry_restores_state_before_regenerating(db, monkeypatch):
|
||||||
# Retry deletes the last AI action and must roll the scoreboard back to that
|
# Retry must roll the scoreboard back to the AI action's snapshot so
|
||||||
# action's snapshot so regeneration doesn't stack output mutations.
|
# regeneration doesn't stack output mutations on the discarded attempt.
|
||||||
user, adv = _make_adventure(db, {"gold": 20}) # 20 = double-applied bug value
|
user, adv = _make_adventure(db, {"gold": 20}) # 20 = double-applied bug value
|
||||||
_add(db, adv, 0, "start")
|
_add(db, adv, 0, "start")
|
||||||
_add(db, adv, 1, "do", state_before={"gold": 0})
|
_add(db, adv, 1, "do", state_before={"gold": 0})
|
||||||
@@ -197,5 +197,11 @@ def test_retry_restores_state_before_regenerating(db, monkeypatch):
|
|||||||
adventures.retry_action(adv.id, request=None, db=db, user=user)
|
adventures.retry_action(adv.id, request=None, db=db, user=user)
|
||||||
|
|
||||||
assert adv.script_state == {"gold": 10}
|
assert adv.script_state == {"gold": 10}
|
||||||
assert [a.type for a in adv.actions] == ["start", "do"]
|
# The row survives now (it used to be deleted) so the discarded attempt
|
||||||
|
# stays readable — it's kept as variant 0.
|
||||||
|
assert [a.type for a in adv.actions] == ["start", "do", "ai"]
|
||||||
|
last = adv.actions[-1]
|
||||||
|
assert len(last.variants) == 1
|
||||||
|
assert last.variant_index == 0
|
||||||
|
assert last.variants[0]["script_state"] == {"gold": 20} # the attempt's outcome
|
||||||
adventures._active_turns.discard(adv.id)
|
adventures._active_turns.discard(adv.id)
|
||||||
|
|||||||
@@ -90,6 +90,14 @@ export const api = {
|
|||||||
request(`/adventures/${advId}/actions/${actionId}`, { method: 'PATCH', body: JSON.stringify({ text }) }),
|
request(`/adventures/${advId}/actions/${actionId}`, { method: 'PATCH', body: JSON.stringify({ text }) }),
|
||||||
deleteAction: (advId, actionId) =>
|
deleteAction: (advId, actionId) =>
|
||||||
request(`/adventures/${advId}/actions/${actionId}`, { method: 'DELETE' }),
|
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) =>
|
sendAction: (advId, payload, handlers, signal) =>
|
||||||
streamSSE(`/adventures/${advId}/actions`, 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); }
|
.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; }
|
.action-edit { margin-bottom: 14px; }
|
||||||
/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
|
/* 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.
|
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.
|
// Compact chips shown under an AI message summarizing what state changed.
|
||||||
function StateChangeChips({ changes }) {
|
function StateChangeChips({ changes }) {
|
||||||
if (!changes?.length) return null
|
if (!changes?.length) return null
|
||||||
@@ -1118,6 +1171,9 @@ export default function Play() {
|
|||||||
// (currently "Update from scenario"), which no action count would reflect.
|
// (currently "Update from scenario"), which no action count would reflect.
|
||||||
const [stateKey, setStateKey] = useState(0)
|
const [stateKey, setStateKey] = useState(0)
|
||||||
const [inspectActionId, setInspectActionId] = useState(null)
|
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 storyEndRef = useRef(null)
|
||||||
const abortRef = useRef(null)
|
const abortRef = useRef(null)
|
||||||
const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom
|
const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom
|
||||||
@@ -1243,6 +1299,7 @@ export default function Play() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function retry() {
|
function retry() {
|
||||||
|
setPreview(null)
|
||||||
setActions((prev) =>
|
setActions((prev) =>
|
||||||
prev.length && prev[prev.length - 1].type === 'ai' ? prev.slice(0, -1) : prev)
|
prev.length && prev[prev.length - 1].type === 'ai' ? prev.slice(0, -1) : prev)
|
||||||
runTurn(async (signal) => {
|
runTurn(async (signal) => {
|
||||||
@@ -1259,6 +1316,7 @@ export default function Play() {
|
|||||||
|
|
||||||
async function undo() {
|
async function undo() {
|
||||||
setToast(null)
|
setToast(null)
|
||||||
|
setPreview(null)
|
||||||
try {
|
try {
|
||||||
setActions(await api.undo(id))
|
setActions(await api.undo(id))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1348,6 +1406,9 @@ export default function Play() {
|
|||||||
// Drop cap goes on the first narrated beat only. `firstNarrationId`
|
// Drop cap goes on the first narrated beat only. `firstNarrationId`
|
||||||
// is derived once above rather than per row.
|
// is derived once above rather than per row.
|
||||||
const opening = action.id === firstNarrationId
|
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 ? (
|
return editing?.id === action.id ? (
|
||||||
<div key={action.id} className="action-edit">
|
<div key={action.id} className="action-edit">
|
||||||
@@ -1365,9 +1426,27 @@ export default function Play() {
|
|||||||
<Fragment key={action.id}>
|
<Fragment key={action.id}>
|
||||||
{sceneBreak && <div className="scene-break" aria-hidden="true">❖</div>}
|
{sceneBreak && <div className="scene-break" aria-hidden="true">❖</div>}
|
||||||
<div className={`action ${isPlayer ? 'player' : ''}${opening ? ' opening' : ''}`}>
|
<div className={`action ${isPlayer ? 'player' : ''}${opening ? ' opening' : ''}`}>
|
||||||
<ReasoningBlock text={action.reasoning} />
|
<ReasoningBlock text={previewing ? previewing.reasoning : action.reasoning} />
|
||||||
{renderEmphasis(action.text)}
|
{renderEmphasis(previewing ? previewing.text : action.text)}
|
||||||
{action.type === 'ai' && <StateChangeChips changes={action.world_changes} />}
|
{/* 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 && (
|
{!busy && (
|
||||||
<span className="action-tools">
|
<span className="action-tools">
|
||||||
{action.type === 'ai' && (
|
{action.type === 'ai' && (
|
||||||
|
|||||||
Reference in New Issue
Block a user