Opening a finished adventure fetched every action in one response: 589.5 kB on
production's longest, and nothing about that curve bends on its own, because a
story only ever gets longer. The page load now brings the newest 60 actions
and the reader pages up from there. On the harness's 600-action fixture that
is 606.0 kB down to 62.6 kB, and -- the part that matters -- it no longer
depends on how long the story is.
Paged by anchor, not by offset. `before_id` is the oldest action the caller
holds; the server returns what precedes it. An offset counted back from the
newest would shift every older position the moment a turn lands, which is
exactly when someone is likely to be scrolling, and the reader would get one
action twice and never see another. It also keeps working when the story stops
being a flat list: comparing indices to order a branch survives the story tree,
treating them as positions does not.
`has_more` comes from fetching one row past the window rather than from
counting. A deleted anchor -- undo, mid-scroll -- reports the end rather than
guessing and serving a page the reader already has.
GET /{id}/actions and POST /{id}/undo now return {actions, total, has_more}
instead of a bare list. Undo is the action most likely to be repeated several
times running, so having it re-fetch the whole story would have undone the
paging on the worst case.
The adventure payload gets its window through set_committed_value rather than
by assignment: the actions relationship cascades delete-orphan, so assigning a
60-item list to it would delete everything outside the window on the next
flush.
In Play.jsx the prepend is followed by a useLayoutEffect that restores the
scroll position, before paint, so the story does not jump. Loading starts 400px
from the top rather than at it, guarded by a ref because scroll fires far
faster than React re-renders. There is a button as well as the scroll trigger:
on a short viewport the transcript may not be tall enough to scroll at all, and
a reader who cannot scroll must still be able to reach the beginning.
Verified against a running backend and a 220-action adventure: the page load
returns 60 of 220 ending on the newest, and walking back from an anchor returns
exactly the actions before it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
418 lines
12 KiB
Python
418 lines
12 KiB
Python
from datetime import datetime
|
|
from typing import Annotated, Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
|
|
|
from . import images
|
|
|
|
# Length caps (Phase 9). The VARCHAR ones are correctness, not just abuse
|
|
# limits: Postgres enforces column lengths (SQLite never did), so anything
|
|
# longer must be a 422 here rather than a 500 at INSERT. Text-column caps are
|
|
# generous abuse ceilings a legitimate player won't hit.
|
|
NAME_MAX = 200 # titles/names — VARCHAR(200)
|
|
TAGS_MAX = 500 # VARCHAR(500)
|
|
CARD_TYPE_MAX = 100 # VARCHAR(100)
|
|
PROSE_MAX = 50_000 # memory, author's note, prompts, entries, notes...
|
|
SCRIPT_MAX = 200_000 # one JS source
|
|
ACTION_MAX = 20_000 # one player action
|
|
MEMORY_TEXT_MAX = 5_000
|
|
# A scenario cover image, stored inline as a base64 data URI. 400x300 WebP at
|
|
# the quality the editor encodes lands around 20-40 KB; 400 KB leaves room for
|
|
# a client that downscales less aggressively without letting anyone park a
|
|
# multi-megabyte PNG in a row that gets read on every list request.
|
|
IMAGE_MAX = 400_000
|
|
ICON_MAX = 16 # one emoji/glyph — VARCHAR(16)
|
|
|
|
Name = Annotated[str, Field(max_length=NAME_MAX)]
|
|
Tags = Annotated[str, Field(max_length=TAGS_MAX)]
|
|
CardType = Annotated[str, Field(max_length=CARD_TYPE_MAX)]
|
|
Prose = Annotated[str, Field(max_length=PROSE_MAX)]
|
|
ScriptSource = Annotated[str, Field(max_length=SCRIPT_MAX)]
|
|
ActionText = Annotated[str, Field(max_length=ACTION_MAX)]
|
|
Image = Annotated[str, Field(max_length=IMAGE_MAX)]
|
|
Icon = Annotated[str, Field(max_length=ICON_MAX)]
|
|
|
|
|
|
class ORMModel(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
# ---------- Story cards ----------
|
|
|
|
class StoryCardBase(BaseModel):
|
|
type: CardType = ""
|
|
name: Name = ""
|
|
keys: Prose = ""
|
|
entry: Prose = ""
|
|
notes: Prose = ""
|
|
|
|
|
|
class StoryCardCreate(StoryCardBase):
|
|
scenario_id: int | None = None
|
|
adventure_id: int | None = None
|
|
|
|
|
|
class StoryCardUpdate(BaseModel):
|
|
type: CardType | None = None
|
|
name: Name | None = None
|
|
keys: Prose | None = None
|
|
entry: Prose | None = None
|
|
notes: Prose | None = None
|
|
|
|
|
|
class StoryCardOut(ORMModel, StoryCardBase):
|
|
id: int
|
|
scenario_id: int | None
|
|
adventure_id: int | None
|
|
|
|
|
|
# ---------- Scenarios ----------
|
|
|
|
class ScenarioBase(BaseModel):
|
|
title: Name = "Untitled Scenario"
|
|
description: Prose = ""
|
|
prompt: Prose = ""
|
|
memory: Prose = ""
|
|
authors_note: Prose = ""
|
|
ai_instructions: Prose = ""
|
|
tags: Tags = ""
|
|
# Cover art — an https URL or a base64 data URI. See app/images.py.
|
|
image: Image = ""
|
|
# Emoji/glyph shown when `image` is empty.
|
|
icon: Icon = ""
|
|
# Phase 12: RPG world-state template (stat defs, bands, rules, milestones).
|
|
# None means no RPG layer.
|
|
stat_schema: dict | None = None
|
|
|
|
|
|
class ScenarioCreate(ScenarioBase):
|
|
pass
|
|
|
|
|
|
class ScenarioUpdate(BaseModel):
|
|
title: Name | None = None
|
|
description: Prose | None = None
|
|
prompt: Prose | None = None
|
|
memory: Prose | None = None
|
|
authors_note: Prose | None = None
|
|
ai_instructions: Prose | None = None
|
|
tags: Tags | None = None
|
|
image: Image | None = None
|
|
icon: Icon | None = None
|
|
stat_schema: dict | None = None
|
|
script_ids: list[int] | None = None
|
|
|
|
|
|
class ScenarioOut(ORMModel, ScenarioBase):
|
|
id: int
|
|
is_public: bool = False # shared demo content — read-only for everyone
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
story_cards: list[StoryCardOut] = []
|
|
scripts: list["ScriptOut"] = []
|
|
|
|
|
|
class ScenarioListItem(ORMModel):
|
|
id: int
|
|
title: str
|
|
description: str
|
|
tags: str
|
|
is_public: bool = False
|
|
updated_at: datetime
|
|
# Read off the row so `image_url` can be derived, but excluded from the
|
|
# response: a list of base64 data URIs would be megabytes of JSON.
|
|
image: str = Field("", exclude=True)
|
|
icon: str = ""
|
|
|
|
@computed_field
|
|
@property
|
|
def image_url(self) -> str:
|
|
return images.public_url(self.id, self.image, self.updated_at)
|
|
|
|
|
|
# ---------- Adventures ----------
|
|
|
|
class AdventureCreate(BaseModel):
|
|
scenario_id: int | None = None
|
|
title: Name | None = None
|
|
# ${Placeholder} values collected from the player at start (AI Dungeon behavior).
|
|
placeholders: dict[str, str] = {}
|
|
|
|
|
|
class AdventureUpdate(BaseModel):
|
|
title: Name | None = None
|
|
memory: Prose | None = None
|
|
authors_note: Prose | None = None
|
|
ai_instructions: Prose | None = None
|
|
story_summary: Prose | None = None
|
|
auto_summarize: bool | None = None
|
|
memory_bank_enabled: bool | None = None
|
|
|
|
|
|
class AdventureRefresh(BaseModel):
|
|
"""Body for "Update from scenario". `placeholders` supplies answers the
|
|
adventure has no stored value for (see AdventureCreate.placeholders); they
|
|
are merged over the stored ones and saved."""
|
|
|
|
placeholders: dict[str, str] = {}
|
|
|
|
|
|
class RefreshPlan(BaseModel):
|
|
"""What a refresh would change — drives the confirm dialog."""
|
|
|
|
scenario_id: int
|
|
scenario_title: str
|
|
has_changes: bool
|
|
# field name -> {"old": ..., "new": ...}, only for fields that differ.
|
|
fields: dict[str, dict] = {}
|
|
# {"added"|"updated"|"removed": [card name, ...]}
|
|
cards: dict[str, list[str]] = {}
|
|
# {"added"|"removed": [stat path, ...]} — live values are otherwise kept.
|
|
world_state: dict[str, list[str]] = {}
|
|
# ${Placeholder} names the scenario asks for that the adventure has no
|
|
# stored answer to; the client must collect these and send them back.
|
|
placeholders_needed: list[str] = []
|
|
|
|
|
|
class ActionOut(ORMModel):
|
|
id: int
|
|
adventure_id: int
|
|
index: int
|
|
type: str
|
|
text: str
|
|
reasoning: str | None = None
|
|
# Phase 12: compact RPG state changes for this turn (from the model property).
|
|
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
|
|
|
|
|
|
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):
|
|
text: ActionText
|
|
|
|
|
|
class ActionCreate(BaseModel):
|
|
type: Literal["do", "say", "story", "continue"]
|
|
text: ActionText = ""
|
|
|
|
|
|
class AdventureOut(ORMModel):
|
|
id: int
|
|
scenario_id: int | None
|
|
title: str
|
|
memory: str
|
|
authors_note: str
|
|
ai_instructions: str
|
|
story_summary: str
|
|
auto_summarize: bool
|
|
memory_bank_enabled: bool
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
story_cards: list[StoryCardOut] = []
|
|
# The NEWEST window of the story, not all of it — older pages arrive from
|
|
# GET /{id}/actions as the reader scrolls up. `action_count` is the whole
|
|
# story's length, which is how the client knows there is more above.
|
|
actions: list[ActionOut] = []
|
|
action_count: int = 0
|
|
|
|
|
|
class ActionPage(BaseModel):
|
|
"""A slice of the story, counted back from the newest action."""
|
|
|
|
actions: list[ActionOut] = []
|
|
total: int = 0
|
|
# Whether anything older than this slice exists. Computed server-side so
|
|
# the client never has to do arithmetic on positions to find the end.
|
|
has_more: bool = False
|
|
|
|
|
|
# ---------- Memory bank (Phase 6) ----------
|
|
|
|
class MemoryOut(ORMModel):
|
|
id: int
|
|
adventure_id: int
|
|
text: str
|
|
pinned: bool
|
|
forgotten: bool
|
|
embedded: bool # model property: embedding vector present
|
|
use_count: int
|
|
last_used_at: datetime | None
|
|
source_start: int | None
|
|
source_end: int | None
|
|
created_at: datetime
|
|
|
|
|
|
class MemoryCreate(BaseModel):
|
|
text: Annotated[str, Field(max_length=MEMORY_TEXT_MAX)]
|
|
|
|
|
|
class MemoryUpdate(BaseModel):
|
|
text: Annotated[str, Field(max_length=MEMORY_TEXT_MAX)] | None = None
|
|
pinned: bool | None = None
|
|
forgotten: bool | None = None
|
|
|
|
|
|
class AdventureListItem(ORMModel):
|
|
id: int
|
|
scenario_id: int | None
|
|
scenario_title: str | None = None
|
|
title: str
|
|
updated_at: datetime
|
|
action_count: int = 0
|
|
# "Where you left off" — the tail of the most recent narrative beat, so a
|
|
# Continue card can show the story instead of just a turn count.
|
|
snippet: str = ""
|
|
# Cover art inherited from the parent scenario (see app/images.py).
|
|
image_url: str = ""
|
|
icon: str = ""
|
|
|
|
|
|
# ---------- Scripts ----------
|
|
|
|
class ScriptBase(BaseModel):
|
|
name: Name = "Untitled Script"
|
|
description: Prose = ""
|
|
library_js: ScriptSource = ""
|
|
input_js: ScriptSource = ""
|
|
context_js: ScriptSource = ""
|
|
output_js: ScriptSource = ""
|
|
|
|
|
|
class ScriptCreate(ScriptBase):
|
|
pass
|
|
|
|
|
|
class ScriptUpdate(BaseModel):
|
|
name: Name | None = None
|
|
description: Prose | None = None
|
|
library_js: ScriptSource | None = None
|
|
input_js: ScriptSource | None = None
|
|
context_js: ScriptSource | None = None
|
|
output_js: ScriptSource | None = None
|
|
|
|
|
|
class ScriptOut(ORMModel, ScriptBase):
|
|
id: int
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class ScriptTestRequest(BaseModel):
|
|
hook: Literal["input", "context", "output"]
|
|
text: Prose = ""
|
|
state: dict = {}
|
|
|
|
|
|
class AdventureScriptOut(ORMModel):
|
|
id: int
|
|
adventure_id: int
|
|
position: int
|
|
enabled: bool
|
|
name: str
|
|
description: str
|
|
library_js: str
|
|
input_js: str
|
|
context_js: str
|
|
output_js: str
|
|
# Set by the router (not stored): True when a syncable library version
|
|
# exists whose code differs from this copy; None when nothing to sync.
|
|
out_of_date: bool | None = None
|
|
|
|
|
|
class AdventureScriptUpdate(BaseModel):
|
|
enabled: bool | None = None
|
|
library_js: ScriptSource | None = None
|
|
input_js: ScriptSource | None = None
|
|
context_js: ScriptSource | None = None
|
|
output_js: ScriptSource | None = None
|
|
|
|
|
|
# ---------- Auth (Phase 8) ----------
|
|
|
|
class AuthCredentials(BaseModel):
|
|
email: Annotated[str, Field(max_length=320)] # VARCHAR(320)
|
|
# Upper bound keeps scrypt cost flat — hashing megabyte "passwords" is CPU
|
|
# an attacker would otherwise get for free.
|
|
password: Annotated[str, Field(max_length=128)]
|
|
|
|
|
|
# ---------- Settings ----------
|
|
|
|
class SettingsOut(ORMModel):
|
|
endpoint_url: str
|
|
# The key itself is never echoed back (encrypted at rest, write-only).
|
|
has_api_key: bool
|
|
model: str
|
|
api_mode: str
|
|
temperature: float
|
|
max_output_tokens: int
|
|
reasoning_max_tokens: int
|
|
context_token_budget: int
|
|
narrator_prompt: str
|
|
stream: bool
|
|
summary_model: str
|
|
embedding_model: str
|
|
memory_bank_capacity: int
|
|
memory_top_k: int
|
|
|
|
|
|
ScenarioOut.model_rebuild()
|
|
|
|
|
|
# ---------- AI Chat (power users) ----------
|
|
# A scratchpad for talking to a model directly, with no story framing. Nothing
|
|
# is persisted server-side, so these caps are purely per-request abuse limits.
|
|
|
|
CHAT_MESSAGE_MAX = 100_000 # one message
|
|
CHAT_TOTAL_MAX = 400_000 # whole conversation sent up per request
|
|
CHAT_MESSAGES_MAX = 200 # turns per request
|
|
|
|
|
|
class ChatMessage(BaseModel):
|
|
role: Literal["system", "user", "assistant"]
|
|
content: Annotated[str, Field(max_length=CHAT_MESSAGE_MAX)]
|
|
|
|
|
|
class ChatRequest(BaseModel):
|
|
messages: Annotated[list[ChatMessage], Field(min_length=1, max_length=CHAT_MESSAGES_MAX)]
|
|
# Empty/omitted = fall back to the user's configured model.
|
|
model: Name | None = None
|
|
temperature: Annotated[float, Field(ge=0, le=5)] | None = None
|
|
max_tokens: Annotated[int, Field(ge=1, le=100_000)] | None = None
|
|
|
|
|
|
class SettingsUpdate(BaseModel):
|
|
endpoint_url: Annotated[str, Field(max_length=500)] | None = None # VARCHAR(500)
|
|
# Encryption expands the stored value ~4/3 into the same VARCHAR(500):
|
|
# 256 plaintext chars is the largest safe input ("enc:" + Fernet + base64).
|
|
api_key: Annotated[str, Field(max_length=256)] | None = None
|
|
model: Name | None = None
|
|
api_mode: Annotated[str, Field(max_length=20)] | None = None
|
|
temperature: Annotated[float, Field(ge=0, le=5)] | None = None
|
|
max_output_tokens: Annotated[int, Field(ge=1, le=100_000)] | None = None
|
|
# -1 = explicitly off (sends `reasoning: {effort: none}`); 0 = send nothing.
|
|
reasoning_max_tokens: Annotated[int, Field(ge=-1, le=100_000)] | None = None
|
|
context_token_budget: Annotated[int, Field(ge=256, le=200_000)] | None = None
|
|
narrator_prompt: Prose | None = None
|
|
stream: bool | None = None
|
|
summary_model: Name | None = None
|
|
embedding_model: Name | None = None
|
|
memory_bank_capacity: Annotated[int, Field(ge=1, le=1000)] | None = None
|
|
memory_top_k: Annotated[int, Field(ge=1, le=50)] | None = None
|