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:
+15
-85
@@ -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
@@ -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`),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user