Files
interactive-story/frontend/src/api.js
T
parththakkar106andClaude Opus 5 84827f0f37 Put the tree on the screen
The pager could only step between attempts, and stepping has nothing to say
about the thing the tree exists for: taking a path the story moved past and
keeping both. Every attempt has been its own node since SP4, so a chip is a
node now, and "take this path" forks — or simply switches, when the turn is
still the tip and its attempts are leaves nobody has built on.

Beside it, a Branches panel: every line the story has taken, with where each
left its parent and where it ends, and switch, rename and delete-with-confirm.
It sits with Plot/Memory/Scripts/Insights rather than inventing a new place to
put a rail. An unnamed branch is drawn from its fork depth, never from its
position in the list — a position shifts the moment a branch above it goes.

A spatial per-node map was considered and deliberately not built. At the size
this has to be verified against it is a second windowing problem, and it can be
added later without a new endpoint, since the rail and a map read the same
GET /branches. VariantOut grows an id because a fork is addressed by the node
being taken, not by an ordinal in a group that renumbers.

Driven by hand against the 602-action fixture, which found one bug that no test
could: the panel refreshed on actions.length, and a fork swaps a 60-action
window for another 60-action window, so it went on drawing a one-branch tree
while the story was already on the second. It keys off the counter adoptWindow
bumps now.

The scroll path was driven at the same time — three prepends of ~16,200 px, the
same node holding viewport top 792 to 787, never thrown to the end. That closes
the standing gap in this project. Console clean.

396 tests, build clean, no new lint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
2026-08-18 19:14:07 +05:30

195 lines
9.3 KiB
JavaScript

// Multi-user mode: a 401 means our session cookie is missing/stale. Hitting
// /api/auth/me creates a fresh guest session, after which the original call
// is retried once.
async function ensureSession() {
await fetch('/api/auth/me')
}
async function request(path, options = {}, isRetry = false) {
const resp = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (resp.status === 401 && !isRetry && path !== '/auth/me') {
await ensureSession()
return request(path, options, true)
}
if (!resp.ok) {
let detail = resp.statusText
try {
const body = await resp.json()
detail = body.detail || detail
} catch { /* non-JSON error body */ }
throw new Error(detail)
}
if (resp.status === 204) return null
return resp.json()
}
// POSTs to an SSE endpoint and dispatches events: {type: 'player'|'chunk'|'done'|'error', ...}
async function streamSSE(path, payload, onEvent, signal, isRetry = false) {
const resp = await fetch(`/api${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal,
})
if (resp.status === 401 && !isRetry) {
await ensureSession()
return streamSSE(path, payload, onEvent, signal, true)
}
if (!resp.ok) {
let detail = resp.statusText
try { detail = (await resp.json()).detail || detail } catch { /* non-JSON */ }
throw new Error(detail)
}
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split('\n\n')
buffer = events.pop() // keep incomplete tail
for (const block of events) {
for (const line of block.split('\n')) {
if (line.startsWith('data:')) onEvent(JSON.parse(line.slice(5)))
}
}
}
}
export const api = {
// Auth (Phase 8 — no-ops in local mode beyond getMe)
getMe: () => request('/auth/me'),
register: (email, password) =>
request('/auth/register', { method: 'POST', body: JSON.stringify({ email, password }) }),
login: (email, password) =>
request('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
logout: () => request('/auth/logout', { method: 'POST' }),
// Scenarios
listScenarios: () => request('/scenarios'),
getScenario: (id) => request(`/scenarios/${id}`),
createScenario: (data) => request('/scenarios', { method: 'POST', body: JSON.stringify(data) }),
updateScenario: (id, data) => request(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteScenario: (id) => request(`/scenarios/${id}`, { method: 'DELETE' }),
// Adventures
listAdventures: () => request('/adventures'),
getAdventure: (id) => request(`/adventures/${id}`),
getScriptState: (id) => request(`/adventures/${id}/script-state`),
getWorldState: (id) => request(`/adventures/${id}/world-state`),
overrideWorldState: (id, overrides) =>
request(`/adventures/${id}/world-state`, { method: 'PUT', body: JSON.stringify(overrides) }),
createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }),
updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }),
// A page of the story, walking backwards. `beforeId` is the oldest action
// already on screen; omit it for the newest window. Anchored on an action
// rather than an offset so a turn landing mid-scroll cannot shift the page
// out from under the reader.
getActions: (advId, { beforeId, limit } = {}) => {
const params = new URLSearchParams()
if (beforeId != null) params.set('before_id', beforeId)
if (limit != null) params.set('limit', limit)
const query = params.toString()
return request(`/adventures/${advId}/actions${query ? `?${query}` : ''}`)
},
updateAction: (advId, actionId, text) =>
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 }),
}),
// The story tree (Phase 14). One request draws the whole shape however many
// forks there are. The three that change it answer with the story as it now
// stands, so the caller replaces its window instead of reloading everything.
listBranches: (advId) => request(`/adventures/${advId}/branches`),
switchBranch: (advId, branchId) =>
request(`/adventures/${advId}/branches/${branchId}/switch`, { method: 'POST' }),
renameBranch: (advId, branchId, name) =>
request(`/adventures/${advId}/branches/${branchId}`, {
method: 'PATCH', body: JSON.stringify({ name }),
}),
deleteBranch: (advId, branchId) =>
request(`/adventures/${advId}/branches/${branchId}`, { method: 'DELETE' }),
// Take the story down one attempt. A fork only when it has to be: while the
// attempts are still at the tip they are leaves, and the server switches.
forkFromAttempt: (advId, actionId) =>
request(`/adventures/${advId}/actions/${actionId}/fork`, { method: 'POST' }),
sendAction: (advId, payload, handlers, signal) =>
streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal),
retry: (advId, handlers, signal) => streamSSE(`/adventures/${advId}/retry`, {}, handlers, signal),
exportAdventure: (id) => request(`/adventures/${id}/export`),
importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }),
undo: (advId) => request(`/adventures/${advId}/undo`, { method: 'POST' }),
getAdventureContext: (advId) => request(`/adventures/${advId}/context`),
// "Update from scenario": GET describes what would change, POST applies it.
previewRefresh: (advId) => request(`/adventures/${advId}/refresh`),
refreshFromScenario: (advId, placeholders = {}) =>
request(`/adventures/${advId}/refresh`, {
method: 'POST', body: JSON.stringify({ placeholders }),
}),
getActionContext: (advId, actionId) => request(`/adventures/${advId}/actions/${actionId}/context`),
// Memory bank
listMemories: (advId) => request(`/adventures/${advId}/memories`),
createMemory: (advId, text) =>
request(`/adventures/${advId}/memories`, { method: 'POST', body: JSON.stringify({ text }) }),
updateMemory: (advId, memoryId, data) =>
request(`/adventures/${advId}/memories/${memoryId}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteMemory: (advId, memoryId) =>
request(`/adventures/${advId}/memories/${memoryId}`, { method: 'DELETE' }),
listAdventureScripts: (advId) => request(`/adventures/${advId}/scripts`),
updateAdventureScript: (advId, scriptId, data) =>
request(`/adventures/${advId}/scripts/${scriptId}`, { method: 'PATCH', body: JSON.stringify(data) }),
syncAdventureScript: (advId, scriptId) =>
request(`/adventures/${advId}/scripts/${scriptId}/sync`, { method: 'POST' }),
// Scripts
listScripts: () => request('/scripts'),
getScript: (id) => request(`/scripts/${id}`),
createScript: (data) => request('/scripts', { method: 'POST', body: JSON.stringify(data) }),
updateScript: (id, data) => request(`/scripts/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteScript: (id) => request(`/scripts/${id}`, { method: 'DELETE' }),
testScript: (id, data) => request(`/scripts/${id}/test`, { method: 'POST', body: JSON.stringify(data) }),
exportScript: (id) => request(`/scripts/${id}/export`),
importScript: (bundle) => request('/scripts/import', { method: 'POST', body: JSON.stringify(bundle) }),
// Scenario import/export
exportScenario: (id) => request(`/scenarios/${id}/export`),
importScenario: (bundle) => request('/scenarios/import', { method: 'POST', body: JSON.stringify(bundle) }),
// Story cards
createStoryCard: (data) => request('/story-cards', { method: 'POST', body: JSON.stringify(data) }),
updateStoryCard: (id, data) => request(`/story-cards/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteStoryCard: (id) => request(`/story-cards/${id}`, { method: 'DELETE' }),
// Bulk import/export in AI Dungeon world-info format. `owner` is
// { scenario_id } or { adventure_id }.
exportStoryCards: (owner) => request(`/story-cards/export?${new URLSearchParams(owner)}`),
importStoryCards: (payload) => request('/story-cards/import', { method: 'POST', body: JSON.stringify(payload) }),
// AI Chat (power users only — 404 for everyone else)
getChatConfig: () => request('/chat/config'),
chatStream: (payload, handlers, signal) => streamSSE('/chat/stream', payload, handlers, signal),
// Debug
getDebugRequests: () => request('/debug/requests'),
// Settings
getSettings: () => request('/settings'),
updateSettings: (data) => request('/settings', { method: 'PUT', body: JSON.stringify(data) }),
testConnection: () => request('/settings/test', { method: 'POST' }),
}