M2: cut the hosted product away from the local one

94 files, +1,395 -6,578. Three files are new; twenty-four are gone. The
milestone is subtraction, and what is left is the single-user local
storyteller the specification describes.

Removed in full: campaign scripting and its QuickJS sandbox; multi-user
accounts, guest sessions, login, registration and the shared demo key;
the visitor-analytics tables, dashboard and page beacon; the access log
of sign-ins, addresses and devices; per-IP and per-user rate limiting
and quotas; Render deployment config; Postgres and psycopg; cloud
inference providers, the API-key field and the key encryption that
existed to store it; session-cookie signing. None of it was hidden
behind a flag — the routes are gone and answer 404.

Two things were kept that the brief allowed keeping. The `users` table
and its foreign keys stay as an internal ownership detail, because
rewriting them out means a migration across most of the schema to
delete a column that costs nothing; nothing creates a second user and
no request carries an identity. Five inert tables and four inert
columns stay for the same reason, so an M1 campaign database opens
unchanged.

The one addition is app/endpoints.py, which decides where a story may
be sent. Loopback, RFC1918, link-local, unique-local and CGNAT — an
explicit allowlist of networks, not a guess at what `ipaddress` means
by "private", which calls the documentation ranges private and IPv6
loopback reserved. Every address a hostname resolves to must be in it,
so a split answer does not squeak through, and the rule runs both when
the endpoint is saved and before every outbound request, because a name
that resolved to the LAN this morning can resolve elsewhere this
afternoon. Known cloud hosts are named in the refusal so the error says
why rather than looking like broken DNS. TLS is never traded against
it: M1's shared trust context is intact on all four clients and there
is no way to skip verification.

The hardcoded 120-second model timeout is now a setting. That was not
theoretical — on this GPU-less four-core host a cold load of
qwen2.5:3b-instruct took 648.9 seconds to produce the first turn, while
turns 2 to 5 of the same campaign took 3.6 to 13.1. Connect stays short
at 10s so a wrong address still fails fast; the read timeout defaults
to 300s and is bounded at 3600, because "wait longer" must stay a
number.

Two defects found while testing and fixed here. An unknown /api path
fell through the SPA catch-all and came back as HTML with status 200,
so a client asking for JSON parsed a web page instead of learning the
route was gone. And AIDND_CORS_ORIGINS accepted "*", which on an
unauthenticated loopback API would hand every page on the Internet a
write handle on the campaign database; it now refuses to start.

Verified rather than assumed. Offline, on a network with no route out
and no DNS: five turns, retry with both takes retained, restart with an
identical transcript digest, a failed model call leaving the accepted
AI-turn count untouched, and a capture with zero non-loopback unicast
packets. Against a real second machine on the LAN over HTTPS with a
private CA: four turns, restart, and a capture showing 289 packets to
the approved host, 344 loopback, zero anywhere else, zero DNS queries.
Cloud and public endpoints refused with their reasons; no API key
settable; every removed route 404.

604 backend tests pass, down from 648 by the fifteen retired with the
subsystems they tested and up by the twenty-nine added for the endpoint
policy and the removed surface. The scripting tests were not deleted:
eight files used a JavaScript counter as instrumentation for the state
snapshot and rollback machinery, which M2 does not touch, so the
counter moved to the world-state engine and those tests still assert
what they always did. Frontend lint and build are clean; the image
builds, and its wheel-building stage is gone with quickjs.

No M3 work. Undo is still destructive and there is still no Redo.
This commit is contained in:
JesseMarkowitz
2026-09-02 11:27:14 -04:00
parent 1a28a9a708
commit 8c65ae99de
94 changed files with 1384 additions and 6567 deletions
+15 -85
View File
@@ -1,56 +1,23 @@
import { useEffect, useRef, useState } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { api } from './api'
import { AuthModal, ToastHost } from './components'
import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { ToastHost } from './components'
import Embers from './Embers.jsx'
// The shell: the nav bar and whatever page is routed under it.
//
// Upstream also carried the hosted deployment's account furniture here — a
// /auth/me lookup on mount, guest and sign-up prompts, a log-out button, a
// pageview beacon, and two nav links gated on server-side allowlists. M2
// removed the deployment those served. There is one local user, nothing to log
// in to, and nothing counting.
export default function App() {
// null until /auth/me resolves; in local mode multi_user=false hides all auth UI.
const [me, setMe] = useState(null)
const [authMode, setAuthMode] = useState(null) // 'register' | 'login' | null
const [navOpen, setNavOpen] = useState(false) // mobile hamburger menu
const location = useLocation()
const lastPath = useRef(null)
useEffect(() => {
api.getMe().then(setMe).catch(() => {})
}, [])
// One pageview per route the reader actually lands on. Guarded on the path
// rather than fired on every render: StrictMode runs effects twice in dev,
// and a re-render for unrelated state is not a new page.
useEffect(() => {
if (lastPath.current === location.pathname) return
const first = lastPath.current === null
lastPath.current = location.pathname
// document.referrer survives client-side navigation, so it is only honest
// on the first view — after that this was our own page, not a referral.
api.trackPageview(location.pathname, {
referrer: first ? document.referrer : '',
first,
})
}, [location.pathname])
const onAuthed = (newMe, mode) => {
setAuthMode(null)
if (mode === 'login') {
// Different user now — reload so every page refetches its scoped data.
window.location.reload()
} else {
setMe(newMe) // register upgrades the same user in place; data unchanged
}
}
const logout = async () => {
try { await api.logout() } catch { /* already logged out */ }
window.location.reload()
}
return (
<ToastHost>
<Embers />
<nav className="topnav">
<span className="brand">⚔ AI D&D</span>
<span className="brand">⚔ Adventure Storyteller</span>
<button
className="nav-hamburger"
aria-label="Menu"
@@ -69,52 +36,15 @@ export default function App() {
<NavLink to="/scenarios" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Scenarios
</NavLink>
<NavLink to="/scripts" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Scripts
</NavLink>
<NavLink to="/settings" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Settings
</NavLink>
{/* Power-user tooling, not part of the game — hidden for everyone else. */}
{me?.power_user && (
<NavLink to="/chat" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
AI Chat
</NavLink>
)}
{/* Owner only: the site's own traffic, on its own allowlist. */}
{me?.analytics && (
<NavLink to="/analytics" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Visitors
</NavLink>
)}
{me?.multi_user && (
<div className="nav-account">
{me.is_guest ? (
<>
<span className="guest-nudge"
title={me.guest_retention_days
? `Guest adventures are deleted after ${me.guest_retention_days} days without a visit.`
: undefined}>
Playing as guest — sign up to keep your adventures
</span>
<button onClick={() => setAuthMode('login')}>Log in</button>
<button className="primary" onClick={() => setAuthMode('register')}>Sign up</button>
</>
) : (
<>
<span className="account-email" title={me.email}>{me.email}</span>
<button onClick={logout}>Log out</button>
</>
)}
</div>
)}
<NavLink to="/chat" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
AI Chat
</NavLink>
</div>
</nav>
<Outlet context={{ me, setMe }} />
{authMode && (
<AuthModal mode={authMode} onClose={() => setAuthMode(null)} onAuthed={onAuthed}
retentionDays={me?.guest_retention_days} />
)}
<Outlet />
</ToastHost>
)
}
+5 -63
View File
@@ -1,19 +1,11 @@
// 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) {
// There is no session. The API is single-user and loopback-bound, so a request
// either works or reports why. The 401-and-retry dance here served the hosted
// deployment's guest cookies, which M2 removed.
async function request(path, options = {}) {
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 {
@@ -27,17 +19,13 @@ async function request(path, options = {}, isRetry = false) {
}
// POSTs to an SSE endpoint and dispatches events: {type: 'player'|'chunk'|'done'|'error', ...}
async function streamSSE(path, payload, onEvent, signal, isRetry = false) {
async function streamSSE(path, payload, onEvent, signal) {
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 */ }
@@ -61,36 +49,6 @@ async function streamSSE(path, payload, onEvent, signal, isRetry = false) {
}
export const api = {
// Analytics. The beacon is deliberately not a `request()`: it must never
// retry, never bootstrap a session, and never surface an error — a counter
// that can interrupt the app it is counting is worse than no counter.
trackPageview: (path, { referrer = '', first = false } = {}) => {
try {
fetch('/api/analytics/collect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, referrer, first }),
keepalive: true,
}).catch(() => {})
} catch { /* no beacon, no problem */ }
},
getAnalytics: (days) => request(`/analytics/summary?days=${days}`),
getAccessLog: ({ beforeId, kind, q, limit = 50 } = {}) => {
const params = new URLSearchParams({ limit })
if (beforeId != null) params.set('before_id', beforeId)
if (kind) params.set('kind', kind)
if (q) params.set('q', q)
return request(`/analytics/access?${params}`)
},
// 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}`),
@@ -101,7 +59,6 @@ export const api = {
// 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) }),
@@ -185,21 +142,6 @@ export const api = {
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`),
-105
View File
@@ -287,111 +287,6 @@ export function BeginAdventureModal({ title, names = [], onSubmit, onCancel }) {
// Phase 8: register/login for the hosted multi-user mode. `onAuthed(me)` gets
// the fresh /auth/me payload after success.
export function AuthModal({ mode: initialMode, onClose, onAuthed, retentionDays }) {
const [mode, setMode] = useState(initialMode || 'register')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [reveal, setReveal] = useState(false)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const registering = mode === 'register'
// Esc dismisses, like the overlay click already does.
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const switchTo = (next) => {
if (next === mode) return
setMode(next)
setError('')
setReveal(false)
}
const submit = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
try {
const address = email.trim()
const me = registering
? await api.register(address, password)
: await api.login(address, password)
onAuthed(me, mode)
} catch (err) {
setError(err.message)
setBusy(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<form className="modal auth-modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}
aria-labelledby="auth-title">
<div className="auth-crest" aria-hidden="true">❖</div>
<h2 id="auth-title">{registering ? 'Create an account' : 'Welcome back'}</h2>
<p className="modal-hint auth-hint">
{registering
? 'Everything you’ve played as a guest stays with your new account, and you can pick it up from any device.'
: 'Log in to reach your adventures.'}
{/* Guest data really is deleted, so say so where the decision is
being made. The window comes from the server (see cleanup.py) so
it can't drift from what's enforced. */}
{registering && retentionDays ? (
<> Guest adventures are deleted after {retentionDays} days without a visit.</>
) : null}
</p>
<div className="auth-tabs" role="tablist">
<button type="button" role="tab" aria-selected={!registering}
className={`auth-tab${registering ? '' : ' active'}`}
onClick={() => switchTo('login')}>Log in</button>
<button type="button" role="tab" aria-selected={registering}
className={`auth-tab${registering ? ' active' : ''}`}
onClick={() => switchTo('register')}>Sign up</button>
</div>
<label className="field">
<span className="label">Email</span>
<input type="email" autoFocus required value={email}
autoComplete="email" placeholder="you@example.com"
onChange={(e) => setEmail(e.target.value)} />
</label>
<label className="field">
<span className="label">Password</span>
<div className="auth-password">
<input type={reveal ? 'text' : 'password'} required
minLength={registering ? 8 : undefined} value={password}
autoComplete={registering ? 'new-password' : 'current-password'}
onChange={(e) => setPassword(e.target.value)} />
<button type="button" className="auth-reveal" tabIndex={-1}
aria-label={reveal ? 'Hide password' : 'Show password'}
onClick={() => setReveal((r) => !r)}>
{reveal ? 'Hide' : 'Show'}
</button>
</div>
{registering && <span className="auth-help">At least 8 characters.</span>}
</label>
{error && <div className="auth-error" role="alert">{error}</div>}
<div className="modal-buttons">
<button type="button" onClick={onClose}>Cancel</button>
<button type="submit" className="primary" disabled={busy}>
{busy ? 'Please wait…' : registering ? 'Sign up' : 'Log in'}
</button>
</div>
</form>
</div>
)
}
/** A textarea that grows to fit its content instead of sitting at a fixed
height. Editing an AI beat means editing a few paragraphs, and a fixed box
made that a keyhole. CSS min/max-height still bound it (the cap scrolls),
so callers control the floor and ceiling from the stylesheet. */
export function AutoTextarea({ value, ...props }) {
const ref = useRef(null)
-8
View File
@@ -7,11 +7,8 @@ import Adventures from './pages/Adventures.jsx'
import Scenarios from './pages/Scenarios.jsx'
import ScenarioEditor from './pages/ScenarioEditor.jsx'
import Play from './pages/Play'
import Scripts from './pages/Scripts.jsx'
import ScriptEditor from './pages/ScriptEditor.jsx'
import Settings from './pages/Settings.jsx'
import Chat from './pages/Chat.jsx'
import Analytics from './pages/Analytics.jsx'
import { trackKeyboardInset } from './keyboard.js'
import './index.css'
@@ -27,13 +24,8 @@ const router = createBrowserRouter([
{ path: 'scenarios', element: <Scenarios /> },
{ path: 'scenarios/:id', element: <ScenarioEditor /> },
{ path: 'play/:id', element: <Play /> },
{ path: 'scripts', element: <Scripts /> },
{ path: 'scripts/:id', element: <ScriptEditor /> },
{ path: 'settings', element: <Settings /> },
// Power users only — the page redirects home and the API 404s otherwise.
{ path: 'chat', element: <Chat /> },
// Owner only, by a separate allowlist; same redirect-and-404 treatment.
{ path: 'analytics', element: <Analytics /> },
],
},
])
-476
View File
@@ -1,476 +0,0 @@
/* Visit analytics — the owner's view of who came by and what they did.
Everything on this page arrives in a single aggregate response (see
backend/app/analytics.py), so changing the range is one small request, not a
scan of anything. The page is hidden from everyone else: the nav link is
gated on `me.analytics`, this component bounces, and the API 404s.
Charts are plain HTML — a flex row of columns, a row of bars — rather than
SVG or a charting library. At this size that is less code, responsive for
free, and keeps the CSP as tight as it is. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { api } from '../api'
const RANGES = [
{ days: 7, label: '7 days' },
{ days: 30, label: '30 days' },
{ days: 90, label: '90 days' },
]
const nf = new Intl.NumberFormat()
// Weekday + day for a short range, day + month for a long one: a 90-day axis
// has no room for "Mon".
function dayLabel(iso, days) {
const date = new Date(`${iso}T00:00:00Z`)
const opts = days <= 14
? { weekday: 'short', timeZone: 'UTC' }
: { day: 'numeric', month: 'short', timeZone: 'UTC' }
return date.toLocaleDateString(undefined, opts)
}
function fullDate(iso) {
return new Date(`${iso}T00:00:00Z`)
.toLocaleDateString(undefined, { dateStyle: 'medium', timeZone: 'UTC' })
}
/* ---------- Pieces ---------- */
function StatTile({ label, value, hint }) {
return (
<div className="an-tile" title={hint || undefined}>
<div className="an-tile-value">{nf.format(value ?? 0)}</div>
<div className="an-tile-label">{label}</div>
{hint && <div className="an-tile-hint">{hint}</div>}
</div>
)
}
/* A day-by-day column chart. `series` names the stacked segments bottom-up;
one segment means one plain bar and no legend, since the title already says
what it is. */
function DayChart({ title, data, series, days }) {
const [hover, setHover] = useState(null)
const max = Math.max(1, ...data.map((d) => series.reduce((sum, s) => sum + (d[s.key] || 0), 0)))
// Only ever three x labels. Every column labelled is unreadable at 90 days
// and redundant at 7 — the tooltip carries the exact date either way.
const ticks = new Set([0, Math.floor((data.length - 1) / 2), data.length - 1])
const empty = data.every((d) => series.every((s) => !d[s.key]))
return (
<section className="an-card an-chart">
<header className="an-card-head">
<h2>{title}</h2>
{series.length > 1 && (
<div className="an-legend">
{[...series].reverse().map((s) => (
<span key={s.key} className="an-legend-item">
<i className="an-swatch" style={{ background: s.color }} />
{s.label}
</span>
))}
</div>
)}
</header>
<div className="an-plot" onMouseLeave={() => setHover(null)}>
<div className="an-gridline" style={{ bottom: '100%' }}><span>{nf.format(max)}</span></div>
<div className="an-gridline" style={{ bottom: '50%' }}><span>{nf.format(Math.round(max / 2))}</span></div>
<div className="an-columns">
{data.map((point, i) => {
const total = series.reduce((sum, s) => sum + (point[s.key] || 0), 0)
return (
<div
key={point.day}
className={`an-column${hover === i ? ' hot' : ''}`}
onMouseEnter={() => setHover(i)}
onFocus={() => setHover(i)}
tabIndex={0}
aria-label={`${fullDate(point.day)}: ${total}`}
>
<div className="an-stack">
{[...series].reverse().map((s) => (
(point[s.key] || 0) > 0 && (
<div
key={s.key}
className="an-bar"
style={{
height: `${((point[s.key] || 0) / max) * 100}%`,
background: s.color,
}}
/>
)
))}
</div>
{hover === i && (
<div className="an-tip">
<strong>{fullDate(point.day)}</strong>
{series.map((s) => (
<span key={s.key}>
<i className="an-swatch" style={{ background: s.color }} />
{s.label}: {nf.format(point[s.key] || 0)}
</span>
))}
</div>
)}
</div>
)
})}
</div>
</div>
<div className="an-axis">
{data.map((point, i) => (
<span key={point.day}>{ticks.has(i) ? dayLabel(point.day, days) : ''}</span>
))}
</div>
{empty && <div className="an-overlay-empty">Nothing recorded in this range</div>}
</section>
)
}
/* The funnel. Each step's bar is drawn against the first step, so the shape of
the drop-off is the picture; the percentage beside it is of the step above,
which is the number you act on. */
function Funnel({ steps }) {
const top = steps[0]?.count || 0
return (
<section className="an-card">
<header className="an-card-head">
<h2>Where visitors get to</h2>
<span className="an-note">people, counted once each</span>
</header>
{top === 0 ? (
<div className="an-empty">No visitors in this range.</div>
) : (
<div className="an-funnel">
{steps.map((step, i) => {
const previous = i === 0 ? step.count : steps[i - 1].count
const share = previous ? Math.round((step.count / previous) * 100) : 0
return (
<div key={step.step} className="an-funnel-row">
<div className="an-funnel-label">{step.step}</div>
<div className="an-funnel-track">
<div
className="an-funnel-bar"
style={{ width: `${top ? (step.count / top) * 100 : 0}%` }}
/>
</div>
<div className="an-funnel-value">
{nf.format(step.count)}
{i > 0 && <span className="an-funnel-share">{share}%</span>}
</div>
</div>
)
})}
</div>
)}
</section>
)
}
function TopList({ title, rows, note, empty = 'Nothing yet' }) {
const max = Math.max(1, ...rows.map((r) => r.hits))
return (
<section className="an-card">
<header className="an-card-head">
<h2>{title}</h2>
{note && <span className="an-note">{note}</span>}
</header>
{rows.length === 0 ? (
<div className="an-empty">{empty}</div>
) : (
<ul className="an-list">
{rows.map((row) => (
<li key={row.label}>
{/* The bar is the row's own background, so a long label stays
readable on top of it instead of being squeezed beside it. */}
<span className="an-list-fill" style={{ width: `${(row.hits / max) * 100}%` }} />
<span className="an-list-label" title={row.label}>{row.label}</span>
<span className="an-list-value">{nf.format(row.hits)}</span>
</li>
))}
</ul>
)}
</section>
)
}
/* ---------- Access log ---------- */
const KINDS = [
{ key: '', label: 'Everything' },
{ key: 'session', label: 'Sessions' },
{ key: 'login', label: 'Sign-ins' },
{ key: 'register', label: 'Registrations' },
{ key: 'login_failed', label: 'Failed' },
]
const KIND_LABEL = {
session: 'Session',
login: 'Signed in',
register: 'Registered',
login_failed: 'Failed sign-in',
}
function when(iso) {
const date = new Date(iso.endsWith('Z') ? iso : `${iso}Z`)
return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
function AccessLog() {
const [kind, setKind] = useState('')
const [search, setSearch] = useState('')
const [query, setQuery] = useState('')
const [page, setPage] = useState(null) // { events, has_more }
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
// Typing shouldn't fire a request per keystroke against a table scan.
useEffect(() => {
const timer = setTimeout(() => setQuery(search.trim()), 350)
return () => clearTimeout(timer)
}, [search])
useEffect(() => {
let live = true
setPage(null)
api.getAccessLog({ kind, q: query }).then(
(result) => { if (live) { setPage(result); setError(null) } },
(err) => { if (live) setError(err.message) },
)
return () => { live = false }
}, [kind, query])
const loadMore = useCallback(async () => {
if (!page?.events.length || busy) return
setBusy(true)
try {
// Anchored on the oldest row already on screen, so rows arriving while
// this is open can't shift the next page.
const next = await api.getAccessLog({
kind, q: query, beforeId: page.events[page.events.length - 1].id,
})
setPage({ events: [...page.events, ...next.events], has_more: next.has_more })
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}, [page, kind, query, busy])
return (
<section className="an-card">
<header className="an-card-head an-log-head">
<div className="an-ranges">
{KINDS.map((option) => (
<button
key={option.key}
className={kind === option.key ? 'primary' : ''}
onClick={() => setKind(option.key)}
>
{option.label}
</button>
))}
</div>
<input
type="text"
className="search-input an-log-search"
placeholder="Search email, IP, country…"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</header>
{error && <div className="an-empty">Couldn’t load the log: {error}</div>}
{!page && !error && <div className="an-empty">Reading…</div>}
{page && (page.events.length === 0 ? (
<div className="an-empty">
{query || kind ? 'Nothing matches that.' : 'Nothing logged yet.'}
</div>
) : (
<>
<div className="an-table-scroll">
<table className="an-table">
<thead>
<tr>
<th>When</th><th>Who</th><th>Event</th>
<th>IP</th><th>Country</th><th>Device</th>
</tr>
</thead>
<tbody>
{page.events.map((event) => (
<tr key={event.id} className={event.kind === 'login_failed' ? 'failed' : undefined}>
<td className="an-cell-dim">{when(event.at)}</td>
<td>
{event.who}
{event.is_guest && <span className="an-tag">guest</span>}
</td>
<td>{KIND_LABEL[event.kind] || event.kind}</td>
<td className="an-cell-mono">{event.ip || '—'}</td>
<td>{event.country || '—'}</td>
{/* The full user-agent is a wall of text; it lives on the
hover instead of in a column that would push the rest
of the table off screen. */}
<td className="an-cell-dim" title={event.user_agent || undefined}>
{event.device || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{page.has_more && (
<div className="an-more">
<button onClick={loadMore} disabled={busy}>
{busy ? 'Loading…' : 'Load older'}
</button>
</div>
)}
</>
))}
</section>
)
}
/* ---------- Page ---------- */
export default function Analytics() {
const { me } = useOutletContext() ?? {}
const navigate = useNavigate()
const [tab, setTab] = useState('overview')
const [days, setDays] = useState(30)
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const loaded = useRef(false)
// me is null until /auth/me resolves; only bounce once we know.
useEffect(() => {
if (me && !me.analytics) navigate('/', { replace: true })
}, [me, navigate])
useEffect(() => {
if (tab !== 'overview') return undefined
let live = true
api.getAnalytics(days).then(
(result) => { if (live) { setData(result); setError(null); loaded.current = true } },
(err) => { if (live) setError(err.message) },
)
return () => { live = false }
}, [days, tab])
const totals = data?.totals ?? {}
const visitorSeries = useMemo(() => ([
{ key: 'returning', label: 'Returning', color: 'var(--chart-2)' },
{ key: 'new', label: 'New', color: 'var(--chart-1)' },
]), [])
// The server sends new-vs-total; the chart stacks, so it wants the remainder.
const visitorDays = useMemo(
() => (data?.series ?? []).map((d) => ({ ...d, returning: d.visitors - d.new })),
[data],
)
if (me && !me.analytics) return null
return (
<div className="page an-page">
<div className="page-header">
<h1>Visitors</h1>
{tab === 'overview' && (
<div className="an-ranges">
{RANGES.map((range) => (
<button
key={range.days}
className={days === range.days ? 'primary' : ''}
onClick={() => setDays(range.days)}
>
{range.label}
</button>
))}
</div>
)}
</div>
<div className="an-tabs">
<button
className={tab === 'overview' ? 'active' : ''}
onClick={() => setTab('overview')}
>
Overview
</button>
<button
className={tab === 'access' ? 'active' : ''}
onClick={() => setTab('access')}
>
Access log
</button>
</div>
{tab === 'access' && <AccessLog />}
{tab === 'overview' && error && (
<div className="an-empty">Couldn’t load analytics: {error}</div>
)}
{tab === 'overview' && !data && !error && <div className="an-empty">Counting…</div>}
{tab === 'overview' && data && (
<>
<div className="an-tiles">
<StatTile label="Visitors" value={totals.visitors}
hint={`${nf.format(totals.new_visitors || 0)} first-time`} />
<StatTile label="Visits" value={totals.visits}
hint={`${totals.pages_per_visit || 0} pages each`} />
<StatTile label="Pageviews" value={totals.pageviews} />
<StatTile label="Adventures started" value={totals.adventures} />
<StatTile label="Turns played" value={totals.turns}
hint={`${totals.turns_per_visit || 0} per visit`} />
<StatTile label="Sign-ups" value={totals.signups} />
<StatTile label="Demo-key turns" value={totals.demo_turns}
hint="billed to the shared key" />
<StatTile label="Failed turns" value={totals.turn_errors}
hint={`${nf.format(totals.errors || 0)} API errors`} />
</div>
<div className="an-grid">
<DayChart
title="Visitors per day"
data={visitorDays}
series={visitorSeries}
days={days}
/>
<DayChart
title="Turns played per day"
data={data.series}
series={[{ key: 'turns', label: 'Turns', color: 'var(--chart-3)' }]}
days={days}
/>
</div>
<Funnel steps={data.funnel} />
<div className="an-grid">
<TopList title="Pages" rows={data.pages} />
<TopList
title="Where they came from"
rows={data.referrers}
note="per visit"
empty="No referrals yet — every visit was typed or bookmarked."
/>
<TopList title="Scenarios started" rows={data.scenarios}
note="shared scenarios only" empty="No adventures started yet." />
<TopList title="Countries" rows={data.countries} />
<TopList title="Devices" rows={data.devices} />
<TopList title="API errors" rows={data.errors} empty="None — clean run." />
</div>
<p className="an-footnote">
{fullDate(data.since)} – {fullDate(data.until)}, UTC. Your own visits aren’t
counted here. These totals are anonymous — visitors are counted as one-way
hashes, and nothing on this tab can be traced back to a player or their
stories. The access log tab is the separate, identifying record.
</p>
</>
)}
</div>
)
}
+3 -11
View File
@@ -1,12 +1,11 @@
/* AI Chat — a plain scratchpad for talking to a model, with none of the game's
context assembly in the way. Power users only (the backend 404s the routes
for everyone else, and the nav link is hidden).
/* AI Chat — a plain scratchpad for talking to the configured model, with none
of the game's context assembly in the way. Useful for checking a model or a
prompt without starting an adventure.
Deliberately client-side: the conversation lives in localStorage, not the
database. Nothing here is part of an adventure, so there's nothing worth a
migration — and a refresh still keeps what you were poking at. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { api } from '../api'
import { useToast } from '../components'
@@ -64,8 +63,6 @@ function Message({ message, onDelete }) {
}
export default function Chat() {
const { me } = useOutletContext() ?? {}
const navigate = useNavigate()
const toast = useToast()
const initial = useRef(load()).current
@@ -88,10 +85,6 @@ export default function Chat() {
const inputRef = useRef(null)
const pinnedRef = useRef(true)
// me is null until /auth/me resolves; only bounce once we know.
useEffect(() => {
if (me && !me.power_user) navigate('/', { replace: true })
}, [me, navigate])
useEffect(() => {
api.getChatConfig().then(setConfig).catch(() => setConfig(null))
@@ -226,7 +219,6 @@ export default function Chat() {
}
}
if (me && !me.power_user) return null
const waitingForFirstToken = streaming === '' && reasoningStream === null
const canRegenerate = !busy && messages.some((m) => m.role === 'user')
@@ -1,99 +0,0 @@
// The left drawer: the script state an adventure's scripts read and write.
//
// `StateTree` and `StateValue` render a value of any shape, because script
// state is whatever the scripts put there.
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api'
// Renders one script-state value. A primitive renders inline, typed and
// colored. An object or an array renders as a collapsible indented tree, and
// the render recurses, so deep state shows its structure rather than one flat
// JSON blob.
function StateValue({ value, depth = 0 }) {
if (value === null || value === undefined) return <span className="jv-null">null</span>
if (typeof value === 'boolean') return <span className="jv-bool">{String(value)}</span>
if (typeof value === 'number') return <span className="jv-number">{value}</span>
if (typeof value === 'string') return <span className="jv-string">{value}</span>
if (Array.isArray(value)) return <StateTree entries={value.map((v, i) => [i, v])} empty="[ ]" depth={depth} />
if (typeof value === 'object') return <StateTree entries={Object.entries(value)} empty="{ }" depth={depth} />
return <span className="jv-string">{String(value)}</span>
}
// Only the first level is expanded by default (depth 0); nested trees start
// collapsed and can be opened on demand.
function StateTree({ entries, empty, depth = 0 }) {
const [open, setOpen] = useState(depth < 1)
if (entries.length === 0) return <span className="jv-empty">{empty}</span>
return (
<div className="jv-tree">
<button className="jv-toggle" onClick={() => setOpen((o) => !o)}>
{open ? '▾' : '▸'} {entries.length} {entries.length === 1 ? 'item' : 'items'}
</button>
{open && (
<ul className="jv-children">
{entries.map(([k, v]) => (
<li key={k}>
<span className="jv-key">{k}</span>
<StateValue value={v} depth={depth + 1} />
</li>
))}
</ul>
)}
</div>
)
}
// Collapsible left rail showing the scripting `state` object — every variable
// scripts read/write via state.x, refreshed after each turn.
function StatusDrawer({ advId, refreshKey }) {
const [open, setOpen] = useState(false)
const [state, setState] = useState(null)
const [failed, setFailed] = useState(false)
const load = useCallback(() => {
api.getScriptState(advId)
.then((r) => { setState(r.state || {}); setFailed(false) })
.catch(() => setFailed(true))
}, [advId])
// Only fetch while open; re-fetch after each turn so values stay live.
useEffect(() => { if (open) load() }, [open, refreshKey, load])
const entries = state ? Object.entries(state) : []
return (
<div className={`status-drawer ${open ? 'open' : ''}`}>
<button className="status-toggle" onClick={() => setOpen((o) => !o)}
title="Script state variables">
{open ? '‹' : '›'}<span className="status-toggle-label">State</span>
</button>
{open && (
<div className="status-body">
<div className="side-panel-header">
<h2>Script State</h2>
<button onClick={load} title="Refresh">↻</button>
</div>
{failed ? (
<div className="empty">Couldn’t load state.</div>
) : entries.length === 0 ? (
<div className="empty">
No variables yet. Scripts that use <code>state</code> will appear here after a turn.
</div>
) : (
<ul className="status-vars">
{entries.map(([k, v]) => (
<li key={k}>
<span className="status-key">{k}</span>
<div className="status-val"><StateValue value={v} /></div>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
export { StatusDrawer }
-2
View File
@@ -6,7 +6,6 @@
const SECTION_LABELS = {
narrator: 'Narrator prompt',
script_context: 'Script context',
ai_instructions: 'AI Instructions',
plot_essentials: 'Plot Essentials',
story_summary: 'Story Summary',
@@ -32,7 +31,6 @@ const SECTION_COLORS = {
narrator: '#7d8fc9',
ai_instructions: '#9c8fd6',
plot_essentials: '#c97dc0',
script_context: '#d99ad0',
story_summary: '#7dc9a2',
used_memories: '#5fb8c9',
world_lore: '#c9b47d',
+4 -25
View File
@@ -17,13 +17,11 @@ import { api } from '../../api'
import { AutoTextarea } from '../../components'
import { StateChangeChips } from './reports'
import { TakePager } from './TakePager'
import { StatusDrawer } from './drawers/StatusDrawer'
import { WorldStateDrawer } from './drawers/WorldStateDrawer'
import { BranchPanel } from './panels/BranchPanel'
import { InsightsPanel } from './panels/InsightsPanel'
import { MemoryPanel } from './panels/MemoryPanel'
import { PlotPanel } from './panels/PlotPanel'
import { ScriptsPanel } from './panels/ScriptsPanel'
const MODES = ['do', 'say', 'story']
const PLAYER_TYPES = ['do', 'say', 'story']
@@ -175,7 +173,7 @@ export default function Play() {
setActions(page.actions)
setTotal(page.total)
setHasMore(page.has_more)
// The script and world state come back to what that branch's tip left
// The world state comes back to what that branch's tip left
// behind, so anything drawn from them is now showing another line's
// numbers until it re-reads.
setStateKey((k) => k + 1)
@@ -264,15 +262,6 @@ export default function Play() {
}
}, [actions, streaming, reasoningStream])
const handleScriptReport = useCallback((script) => {
if (!script) return
if (script.errors?.length) {
setToast({ text: `Script error: ${script.errors[0]}`, isError: true })
} else if (script.message) {
setToast({ text: script.message, isError: false })
}
}, [])
const handleEvent = useCallback((event) => {
if (event.type === 'player') {
setActions((prev) => [...prev, event.action])
@@ -288,17 +277,12 @@ export default function Play() {
setReasoningStream(null)
setActions((prev) => [...prev, event.action])
setTotal((n) => n + 1)
handleScriptReport(event.script)
} else if (event.type === 'stopped') {
setStreaming(null)
setReasoningStream(null)
handleScriptReport(event.script)
} else if (event.type === 'error') {
setStreaming(null)
setReasoningStream(null)
setToast({ text: event.detail, isError: true })
}
}, [handleScriptReport])
}, [])
async function runTurn(run) {
const controller = new AbortController()
@@ -483,7 +467,7 @@ export default function Play() {
setActions(adv.actions)
setTotal(adv.action_count ?? adv.actions.length)
setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length))
// The branch, the script state and the world state can all have moved.
// The branch and the world state can both have moved.
setStateKey((k) => k + 1)
} catch { /* stale beats wrong */ }
}
@@ -516,7 +500,6 @@ export default function Play() {
without changing `actions.length` by one. */}
<WorldStateDrawer advId={id} refreshKey={`${actions.length}:${stateKey}`}
personaName={adventure?.persona_name} />
<StatusDrawer advId={id} refreshKey={`${actions.length}:${stateKey}`} />
<div className="page play-page">
<div className="page-header">
<h1>{adventure.title}</h1>
@@ -525,8 +508,6 @@ export default function Play() {
onClick={() => setPanel(panel === 'plot' ? null : 'plot')}>Plot</button>
<button className={panel === 'memory' ? 'active' : ''}
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
<button className={panel === 'scripts' ? 'active' : ''}
onClick={() => setPanel(panel === 'scripts' ? null : 'scripts')}>Scripts</button>
<button className={panel === 'branches' ? 'active' : ''}
onClick={() => setPanel(panel === 'branches' ? null : 'branches')}>Branches</button>
<button className={panel === 'insights' ? 'active' : ''}
@@ -731,7 +712,7 @@ export default function Play() {
{panel && (
<div className="side-panel">
<div className="side-panel-header">
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', scripts: 'Scripts', branches: 'Branches', insights: 'Insights' }[panel]}</h2>
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', branches: 'Branches', insights: 'Insights' }[panel]}</h2>
<button onClick={() => setPanel(null)}>✕</button>
</div>
{panel === 'plot' ? (
@@ -743,8 +724,6 @@ export default function Play() {
// but deleting a branch deletes the memories that hung off it,
// and that happens without a turn being played.
refreshKey={`${actions.length}:${stateKey}`} />
) : panel === 'scripts' ? (
<ScriptsPanel advId={id} />
) : panel === 'branches' ? (
<BranchPanel
advId={id}
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'
import { api } from '../../../api'
import { SECTION_LABELS, pctLabel, sectionColor } from '../format'
import { CacheReport, ScriptReport, TokenBreakdown, WorldStateReport } from '../reports'
import { CacheReport, TokenBreakdown, WorldStateReport } from '../reports'
function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
const [report, setReport] = useState(null)
@@ -104,7 +104,6 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
<pre>{s.text}</pre>
</div>
))}
<ScriptReport script={report.script} />
<WorldStateReport worldState={report.world_state} />
{report.raw_output && (
<div className="ctx-section">
@@ -1,116 +0,0 @@
// The scripts panel: this adventure's copies of the library scripts.
import { useEffect, useState } from 'react'
import { api } from '../../../api'
import { downloadJSON } from '../../../components'
const SCRIPT_HOOKS = [
['Shared library', 'library_js'],
['onInput', 'input_js'],
['onModelContext', 'context_js'],
['onOutput', 'output_js'],
]
function ScriptsPanel({ advId }) {
const [scripts, setScripts] = useState(null)
const [openId, setOpenId] = useState(null)
useEffect(() => {
api.listAdventureScripts(advId).then(setScripts).catch(() => setScripts([]))
}, [advId])
const [syncingId, setSyncingId] = useState(null)
const toggle = async (script) => {
const updated = await api.updateAdventureScript(advId, script.id, { enabled: !script.enabled })
setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s)))
}
// Pull the latest code from the library script this copy was made from.
const sync = async (script) => {
setSyncingId(script.id)
try {
const updated = await api.syncAdventureScript(advId, script.id)
setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s)))
} finally {
setSyncingId(null)
}
}
// Download as an import-compatible bundle (matches /scripts export), so demo
// scripts can be forked into your own library via the Scripts page's Import.
const download = (s) => {
downloadJSON(
{
name: s.name,
description: s.description,
library: s.library_js,
input: s.input_js,
context: s.context_js,
output: s.output_js,
},
`${(s.name || 'script').replace(/\W+/g, '-')}.json`,
)
}
if (!scripts) return <div className="empty">Loading…</div>
if (scripts.length === 0) {
return (
<div className="empty">
No scripts on this adventure. Attach scripts to a scenario before starting an
adventure from it.
</div>
)
}
return (
<div>
{scripts.map((s) => {
const hooks = SCRIPT_HOOKS.filter(([, f]) => (s[f] || '').trim())
const open = openId === s.id
return (
<div key={s.id} className="adv-script">
<div className="adv-script-head">
<label className="script-attach" style={{ margin: 0 }}>
<input type="checkbox" checked={s.enabled} onChange={() => toggle(s)} />
<span>{s.name}</span>
</label>
<div style={{ display: 'flex', gap: 12, flexShrink: 0 }}>
{s.out_of_date && (
<button
className="linklike"
title="Replace this adventure's copy with the latest from your script library"
disabled={syncingId === s.id}
onClick={() => sync(s)}
>
{syncingId === s.id ? 'Syncing…' : '⟳ Sync from library'}
</button>
)}
<button className="linklike" onClick={() => setOpenId(open ? null : s.id)}>
{open ? 'Hide code' : 'View code'}
</button>
<button className="linklike" onClick={() => download(s)}>Download</button>
</div>
</div>
{s.description && <div className="dim adv-script-desc">{s.description}</div>}
{open && (
<div className="adv-script-code">
{hooks.length === 0 ? (
<div className="empty">This script has no code.</div>
) : (
hooks.map(([label, f]) => (
<div key={f}>
<div className="adv-script-hook">{label}</div>
<pre>{s[f]}</pre>
</div>
))
)}
</div>
)}
</div>
)
})}
</div>
)
}
export { ScriptsPanel }
+3 -40
View File
@@ -1,5 +1,5 @@
// What a turn did, rendered five ways: world-state chips, the world-state
// report, the script report, the cache report, and the token breakdown.
// What a turn did, rendered four ways: world-state chips, the world-state
// report, the cache report, and the token breakdown.
//
// These read a turn's result and render it. None of them fetch.
@@ -109,43 +109,6 @@ function WorldStateReport({ worldState }) {
)
}
function ScriptReport({ script }) {
if (!script || (!script.logs?.length && !script.errors?.length && !script.context_changed)) {
return null
}
return (
<div className="script-report">
<div className="ctx-header" style={{ padding: '4px 0 2px' }}><span>Scripts</span></div>
{script.errors?.map((e, i) => <div key={i} className="script-error">⚠ {e}</div>)}
{script.logs?.length > 0 && (
<pre className="script-logs">{script.logs.join('\n')}</pre>
)}
{script.context_changed && (
<>
<div className="ctx-section" style={{ borderLeftColor: sectionColor('script_context') }}>
<div className="ctx-header" style={{ color: sectionColor('script_context') }}>
<span>Context before script</span>
</div>
<pre>{script.context_before}</pre>
</div>
<div className="ctx-section" style={{ borderLeftColor: sectionColor('script_context') }}>
<div className="ctx-header" style={{ color: sectionColor('script_context') }}>
<span>Context after script (sent to AI)</span>
</div>
<pre>{script.context_after}</pre>
</div>
</>
)}
</div>
)
}
// What the endpoint charged for the turn, and how much of the prompt it read
// back out of its cache instead of billing in full. Only shown on a past turn:
// the "next turn" view has not been sent anywhere yet, so it has no usage. A
// cached read costs a tenth of a fresh one, which is the whole reason the
// prompt is laid out static-first — so this is the number that says whether
// that layout is working.
function CacheReport({ usage }) {
if (!usage) return null
const prompt = usage.prompt_tokens || 0
@@ -244,4 +207,4 @@ function TokenBreakdown({ sections, tokens, used, onJump }) {
)
}
export { StateChangeChips, WorldStateReport, ScriptReport, CacheReport, TokenBreakdown }
export { StateChangeChips, WorldStateReport, CacheReport, TokenBreakdown }
-31
View File
@@ -10,7 +10,6 @@ export default function ScenarioEditor() {
const { id } = useParams()
const navigate = useNavigate()
const [scenario, setScenario] = useState(null)
const [allScripts, setAllScripts] = useState([])
const [status, setStatus] = useState('')
// Raw text buffer for the stat_schema JSON editor + a live parse error.
const [schemaText, setSchemaText] = useState('')
@@ -27,7 +26,6 @@ export default function ScenarioEditor() {
setSchemaText(s.stat_schema ? JSON.stringify(s.stat_schema, null, 2) : '')
setParsedSchema(s.stat_schema || null)
}).catch(() => navigate('/scenarios'))
api.listScripts().then(setAllScripts).catch(() => {})
}, [id, navigate])
const setField = (field, value) => {
@@ -149,15 +147,6 @@ export default function ScenarioEditor() {
}
}
const toggleScript = async (scriptId) => {
const current = scenario.scripts.map((s) => s.id)
const next = current.includes(scriptId)
? current.filter((sid) => sid !== scriptId)
: [...current, scriptId]
const updated = await api.updateScenario(id, { script_ids: next })
setScenario(updated)
}
const exportScenario = async () => {
downloadJSON(await api.exportScenario(id), `${scenario.title.replace(/\W+/g, '-')}.json`)
}
@@ -297,26 +286,6 @@ export default function ScenarioEditor() {
</div>
)}
<div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Attached Scripts</h2>
</div>
{allScripts.length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
No scripts in your library. Create some on the Scripts page.
</div>
) : (
allScripts.map((s) => (
<label key={s.id} className="script-attach">
<input
type="checkbox"
checked={scenario.scripts.some((att) => att.id === s.id)}
onChange={() => toggleScript(s.id)}
/>
<span>{s.name}</span>
<span className="dim">{s.description}</span>
</label>
))
)}
</fieldset>
</div>
)
-149
View File
@@ -1,149 +0,0 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { oneDark } from '@codemirror/theme-one-dark'
import { api } from '../api'
import { Field, downloadJSON } from '../components'
import { useDebouncedSave } from '../hooks/useDebouncedSave'
const SLOTS = [
{ key: 'library_js', label: 'Library', hint: 'Shared code prepended to all three hooks.' },
{ key: 'input_js', label: 'Input', hint: "onInput — modifies the player's input before context construction." },
{ key: 'context_js', label: 'Context', hint: 'onModelContext — modifies the assembled text sent to the model.' },
{ key: 'output_js', label: 'Output', hint: 'onOutput — modifies the model output before it is shown.' },
]
export default function ScriptEditor() {
const { id } = useParams()
const navigate = useNavigate()
const [script, setScript] = useState(null)
const [slot, setSlot] = useState('input_js')
const [status, setStatus] = useState('')
const debounceSave = useDebouncedSave()
// Test-run state
const [testHook, setTestHook] = useState('input')
const [testText, setTestText] = useState('> You look around.')
const [testState, setTestState] = useState('{}')
const [testResult, setTestResult] = useState(null)
useEffect(() => {
api.getScript(id).then(setScript).catch(() => navigate('/scripts'))
}, [id, navigate])
const setField = (field, value) => {
setScript((prev) => ({ ...prev, [field]: value }))
debounceSave(field, async () => {
await api.updateScript(id, { [field]: value })
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
})
}
const runTest = async () => {
let state
try {
state = JSON.parse(testState || '{}')
} catch {
setTestResult({ error: 'Test state is not valid JSON' })
return
}
try {
setTestResult(await api.testScript(id, { hook: testHook, text: testText, state }))
} catch (err) {
setTestResult({ error: err.message })
}
}
const exportScript = async () => {
downloadJSON(await api.exportScript(id), `${script.name.replace(/\W+/g, '-')}.json`)
}
const deleteScript = async () => {
if (!confirm('Delete this script permanently?')) return
await api.deleteScript(id)
navigate('/scripts')
}
if (!script) return null
const activeSlot = SLOTS.find((s) => s.key === slot)
return (
<div className="page">
<div className="page-header">
<h1>Edit Script</h1>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{status}</span>
<button onClick={exportScript}>Export</button>
<button className="danger" onClick={deleteScript}>Delete</button>
</div>
</div>
<Field label="Name" value={script.name} onChange={(v) => setField('name', v)} />
<Field label="Description" value={script.description} onChange={(v) => setField('description', v)}
textarea rows={2} placeholder="What this script does." />
<div className="slot-tabs">
{SLOTS.map((s) => (
<button key={s.key} className={slot === s.key ? 'active' : ''} onClick={() => setSlot(s.key)}>
{s.label}{script[s.key].trim() ? ' •' : ''}
</button>
))}
</div>
<div className="slot-hint">{activeSlot.hint}</div>
<CodeMirror
value={script[slot]}
height="360px"
theme={oneDark}
extensions={[javascript()]}
onChange={(value) => setField(slot, value)}
/>
<div className="page-header" style={{ marginTop: 26 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Test Run</h2>
</div>
<div className="test-run">
<div className="row">
<label className="field" style={{ flex: '0 0 140px' }}>
<span className="label">Hook</span>
<select value={testHook} onChange={(e) => setTestHook(e.target.value)}>
<option value="input">Input</option>
<option value="context">Context</option>
<option value="output">Output</option>
</select>
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Sample text</span>
<input type="text" value={testText} onChange={(e) => setTestText(e.target.value)} />
</label>
</div>
<label className="field">
<span className="label">State (JSON)</span>
<input type="text" value={testState} onChange={(e) => setTestState(e.target.value)} />
</label>
<button className="primary" onClick={runTest}>Run</button>
{testResult && (
<div className="test-result">
{testResult.error ? (
<div className="test-error">{testResult.error}</div>
) : (
<>
<div><span className="label">text</span> <pre>{testResult.text}</pre></div>
{testResult.stop && <div><span className="label">stop</span> true — the AI call would be skipped</div>}
<div><span className="label">state</span> <pre>{JSON.stringify(testResult.state)}</pre></div>
{testResult.storyCards.length > 0 && (
<div><span className="label">storyCards</span> <pre>{JSON.stringify(testResult.storyCards, null, 1)}</pre></div>
)}
{testResult.logs.length > 0 && (
<div><span className="label">logs</span> <pre>{testResult.logs.join('\n')}</pre></div>
)}
</>
)}
</div>
)}
</div>
</div>
)
}
-64
View File
@@ -1,64 +0,0 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { pickJSONFile, useToast } from '../components'
export default function Scripts() {
const [scripts, setScripts] = useState(null)
const navigate = useNavigate()
const toast = useToast()
useEffect(() => {
api.listScripts().then(setScripts).catch(() => setScripts([]))
}, [])
const createScript = async () => {
const script = await api.createScript({
name: 'New Script',
input_js: '// onInput — modify the player\'s input\nconst modifier = (text) => {\n return { text }\n}\nmodifier(text)\n',
})
navigate(`/scripts/${script.id}`)
}
const importScript = async () => {
try {
const bundle = await pickJSONFile()
const script = await api.importScript(bundle)
navigate(`/scripts/${script.id}`)
} catch (err) {
toast(err.message, 'error')
}
}
return (
<div className="page">
<div className="page-header">
<h1>Scripts</h1>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={importScript}>Import</button>
<button className="primary" onClick={createScript}>+ New Script</button>
</div>
</div>
{scripts === null ? null : scripts.length === 0 ? (
<div className="empty">
No scripts yet. Scripts are AI Dungeon-compatible JavaScript modifiers
(onInput / onModelContext / onOutput) you can attach to scenarios.
</div>
) : (
<div className="card-grid">
{scripts.map((s) => (
<div key={s.id} className="card" onClick={() => navigate(`/scripts/${s.id}`)}>
<h3>{s.name}</h3>
<p>{s.description || 'No description'}</p>
<div className="meta">
{['library', 'input', 'context', 'output']
.filter((slot) => s[`${slot}_js`].trim())
.join(' · ') || 'empty'}
</div>
</div>
))}
</div>
)}
</div>
)
}
+19 -58
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { useOutletContext } from 'react-router-dom'
import { api } from '../api'
function DebugLog() {
@@ -46,11 +45,7 @@ function DebugLog() {
}
export default function Settings() {
const { me, setMe } = useOutletContext() ?? {}
const [settings, setSettings] = useState(null)
// The API key is write-only: the server only reports has_api_key, and this
// holds whatever new key the user has typed (empty = leave unchanged).
const [apiKey, setApiKey] = useState('')
const [testResult, setTestResult] = useState(null)
const [saved, setSaved] = useState('')
@@ -60,17 +55,11 @@ export default function Settings() {
const setField = (field, value) => setSettings({ ...settings, [field]: value })
const buildPayload = () => {
const { has_api_key: _hasKey, ...payload } = settings
if (apiKey.trim()) payload.api_key = apiKey.trim()
return payload
}
const buildPayload = () => ({ ...settings })
const afterSave = async () => {
const fresh = await api.getSettings()
setSettings(fresh)
setApiKey('')
if (me?.multi_user) api.getMe().then(setMe).catch(() => {}) // demo banner state
return fresh
}
@@ -85,17 +74,6 @@ export default function Settings() {
setTimeout(() => setSaved(''), 4000)
}
const clearKey = async () => {
try {
await api.updateSettings({ api_key: '' })
await afterSave()
setSaved('API key removed')
} catch (err) {
setSaved(`Failed: ${err.message}`)
}
setTimeout(() => setSaved(''), 4000)
}
const test = async () => {
setTestResult({ pending: true })
try {
@@ -108,7 +86,6 @@ export default function Settings() {
}
if (!settings) return null
const demo = me?.demo
return (
<div className="page" style={{ maxWidth: 640 }}>
@@ -117,34 +94,21 @@ export default function Settings() {
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{saved}</span>
</div>
{demo?.using_demo && (
<div className="demo-banner">
<strong>Using the shared demo key</strong> — {demo.turns_left} of {demo.turns_per_day} free
turns left today (model: {demo.model}). Add your own API key below for unlimited play,
your choice of models, and the memory bank.
</div>
)}
<label className="field">
<span className="label">Endpoint URL (OpenAI-compatible)</span>
<span className="label">Ollama endpoint</span>
<input type="text" value={settings.endpoint_url}
placeholder="http://localhost:11434/v1"
placeholder="http://127.0.0.1:11434/v1"
onChange={(e) => setField('endpoint_url', e.target.value)} />
</label>
<label className="field">
<span className="label">API Key {settings.has_api_key ? '(saved — enter a new one to replace it)' : ''}</span>
<div style={{ display: 'flex', gap: 8 }}>
<input type="password" value={apiKey} style={{ flex: 1 }}
placeholder={settings.has_api_key ? '••••••••••••' : 'Leave empty for local endpoints'}
onChange={(e) => setApiKey(e.target.value)} />
{settings.has_api_key && (
<button type="button" onClick={clearKey}>Remove key</button>
)}
</div>
<span className="label" style={{ marginTop: 4 }}>
Ollama on this machine, or on a machine on your own network. A public
Internet address is refused — your story never leaves your own
infrastructure. An https:// endpoint whose certificate comes from your
own CA works once that CA is installed on this machine.
</span>
</label>
<label className="field">
<span className="label">Model</span>
<input type="text" value={settings.model} placeholder="e.g. llama3.1"
<input type="text" value={settings.model} placeholder="e.g. qwen2.5:3b-instruct"
onChange={(e) => setField('model', e.target.value)} />
</label>
@@ -166,16 +130,13 @@ export default function Settings() {
</label>
</div>
<label className="field">
<span className="label">Reasoning budget (tokens)</span>
<input type="number" min="-1" value={settings.reasoning_max_tokens}
onChange={(e) => setField(
'reasoning_max_tokens', Math.max(-1, Number(e.target.value)))} />
<span className="label">Model timeout (seconds)</span>
<input type="number" min="30" max="3600" value={settings.model_timeout_seconds}
onChange={(e) => setField('model_timeout_seconds', Number(e.target.value))} />
<span className="label" style={{ marginTop: 4 }}>
For reasoning models: separate thinking budget on top of max output tokens,
and thinking is shown collapsed above each response. 0 = nothing extra is
sent (keep 0 for endpoints without reasoning support, e.g. Ollama).
−1 = actively turn reasoning off, for models that think by default
(DeepSeek V4 Flash) — saves the thinking tokens rather than just hiding them.
How long to wait for a turn. Loading a model for the first time can
take minutes on a machine without a GPU, while later turns take
seconds. Raise this if turns time out only after Ollama has been idle.
</span>
</label>
@@ -232,13 +193,13 @@ export default function Settings() {
<div style={{ marginTop: 16, color: testResult.ok ? 'var(--accent)' : 'var(--danger)' }}>
{testResult.pending ? 'Testing…'
: testResult.ok
? `Connected. Models: ${testResult.models?.slice(0, 8).join(', ') || '(none listed)'}`
? (testResult.warning
|| `Connected. Models: ${testResult.models?.slice(0, 8).join(', ') || '(none listed)'}`)
: testResult.detail}
</div>
)}
{/* The provider debug log is a single global buffer — local installs only. */}
{me?.multi_user !== true && <DebugLog />}
<DebugLog />
</div>
)
}