Initial commit: AI Dungeon clone (FastAPI backend + React frontend)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFsGHju9szibJJa2YJcdbg
This commit is contained in:
parththakkar106
2026-07-06 16:08:19 +05:30
co-authored by Claude Fable 5
commit db9f904222
57 changed files with 7804 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=Crimson+Pro:ital,wght@0,400;0,500;0,600;1,400&family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<title>AI D&amp;D</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1643
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/theme-one-dark": "^6.1.3",
"@uiw/react-codemirror": "^4.25.10",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"vite": "^8.1.1"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+24
View File
@@ -0,0 +1,24 @@
import { NavLink, Outlet } from 'react-router-dom'
export default function App() {
return (
<>
<nav className="topnav">
<span className="brand">⚔ AI D&D</span>
<NavLink to="/" end className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Adventures
</NavLink>
<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>
</nav>
<Outlet />
</>
)
}
+115
View File
@@ -0,0 +1,115 @@
async function request(path, options = {}) {
const resp = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!resp.ok) {
let detail = resp.statusText
try {
const body = await resp.json()
detail = body.detail || detail
} catch { /* non-JSON error body */ }
throw new Error(detail)
}
if (resp.status === 204) return null
return resp.json()
}
// POSTs to an SSE endpoint and dispatches events: {type: 'player'|'chunk'|'done'|'error', ...}
async function streamSSE(path, payload, onEvent, signal) {
const resp = await fetch(`/api${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal,
})
if (!resp.ok) {
let detail = resp.statusText
try { detail = (await resp.json()).detail || detail } catch { /* non-JSON */ }
throw new Error(detail)
}
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split('\n\n')
buffer = events.pop() // keep incomplete tail
for (const block of events) {
for (const line of block.split('\n')) {
if (line.startsWith('data:')) onEvent(JSON.parse(line.slice(5)))
}
}
}
}
export const api = {
// Scenarios
listScenarios: () => request('/scenarios'),
getScenario: (id) => request(`/scenarios/${id}`),
createScenario: (data) => request('/scenarios', { method: 'POST', body: JSON.stringify(data) }),
updateScenario: (id, data) => request(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteScenario: (id) => request(`/scenarios/${id}`, { method: 'DELETE' }),
// Adventures
listAdventures: () => request('/adventures'),
getAdventure: (id) => request(`/adventures/${id}`),
createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }),
updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }),
updateAction: (advId, actionId, text) =>
request(`/adventures/${advId}/actions/${actionId}`, { method: 'PATCH', body: JSON.stringify({ text }) }),
deleteAction: (advId, actionId) =>
request(`/adventures/${advId}/actions/${actionId}`, { method: 'DELETE' }),
sendAction: (advId, payload, handlers, signal) =>
streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal),
retry: (advId, handlers, signal) => streamSSE(`/adventures/${advId}/retry`, {}, handlers, signal),
exportAdventure: (id) => request(`/adventures/${id}/export`),
importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }),
undo: (advId) => request(`/adventures/${advId}/undo`, { method: 'POST' }),
getAdventureContext: (advId) => request(`/adventures/${advId}/context`),
getActionContext: (advId, actionId) => request(`/adventures/${advId}/actions/${actionId}/context`),
// Memory bank
listMemories: (advId) => request(`/adventures/${advId}/memories`),
createMemory: (advId, text) =>
request(`/adventures/${advId}/memories`, { method: 'POST', body: JSON.stringify({ text }) }),
updateMemory: (advId, memoryId, data) =>
request(`/adventures/${advId}/memories/${memoryId}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteMemory: (advId, memoryId) =>
request(`/adventures/${advId}/memories/${memoryId}`, { method: 'DELETE' }),
listAdventureScripts: (advId) => request(`/adventures/${advId}/scripts`),
updateAdventureScript: (advId, scriptId, data) =>
request(`/adventures/${advId}/scripts/${scriptId}`, { method: 'PATCH', body: JSON.stringify(data) }),
// Scripts
listScripts: () => request('/scripts'),
getScript: (id) => request(`/scripts/${id}`),
createScript: (data) => request('/scripts', { method: 'POST', body: JSON.stringify(data) }),
updateScript: (id, data) => request(`/scripts/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteScript: (id) => request(`/scripts/${id}`, { method: 'DELETE' }),
testScript: (id, data) => request(`/scripts/${id}/test`, { method: 'POST', body: JSON.stringify(data) }),
exportScript: (id) => request(`/scripts/${id}/export`),
importScript: (bundle) => request('/scripts/import', { method: 'POST', body: JSON.stringify(bundle) }),
// Scenario import/export
exportScenario: (id) => request(`/scenarios/${id}/export`),
importScenario: (bundle) => request('/scenarios/import', { method: 'POST', body: JSON.stringify(bundle) }),
// Story cards
createStoryCard: (data) => request('/story-cards', { method: 'POST', body: JSON.stringify(data) }),
updateStoryCard: (id, data) => request(`/story-cards/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteStoryCard: (id) => request(`/story-cards/${id}`, { method: 'DELETE' }),
// Debug
getDebugRequests: () => request('/debug/requests'),
// Settings
getSettings: () => request('/settings'),
updateSettings: (data) => request('/settings', { method: 'PUT', body: JSON.stringify(data) }),
testConnection: () => request('/settings/test', { method: 'POST' }),
}
+113
View File
@@ -0,0 +1,113 @@
import { useState } from 'react'
export function downloadJSON(obj, filename) {
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export function pickJSONFile() {
return new Promise((resolve, reject) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json,application/json'
input.onchange = () => {
const file = input.files[0]
if (!file) return reject(new Error('No file selected'))
const reader = new FileReader()
reader.onload = () => {
try { resolve(JSON.parse(reader.result)) }
catch { reject(new Error('Not valid JSON')) }
}
reader.onerror = () => reject(new Error('Could not read file'))
reader.readAsText(file)
}
input.click()
})
}
// Unique ${Placeholder} names, in order of first appearance, across the given texts.
export function extractPlaceholders(...texts) {
const names = []
for (const text of texts) {
for (const match of (text || '').matchAll(/\$\{([^}]+)\}/g)) {
const name = match[1].trim()
if (name && !names.includes(name)) names.push(name)
}
}
return names
}
export function PlaceholderModal({ title, names, onSubmit, onCancel }) {
const [values, setValues] = useState(Object.fromEntries(names.map((n) => [n, ''])))
const submit = (e) => {
e.preventDefault()
onSubmit(values)
}
return (
<div className="modal-overlay" onClick={onCancel}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h2>{title}</h2>
<p className="modal-hint">This scenario asks a few questions before you begin.</p>
{names.map((name, i) => (
<label key={name} className="field">
<span className="label">{name}</span>
<input
type="text"
autoFocus={i === 0}
value={values[name]}
onChange={(e) => setValues({ ...values, [name]: e.target.value })}
/>
</label>
))}
<div className="modal-buttons">
<button type="button" onClick={onCancel}>Cancel</button>
<button type="submit" className="primary">Begin Adventure</button>
</div>
</form>
</div>
)
}
export function Field({ label, value, onChange, textarea, rows, placeholder }) {
return (
<label className="field">
<span className="label">{label}</span>
{textarea ? (
<textarea rows={rows || 3} value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
) : (
<input type="text" value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
)}
</label>
)
}
export function StoryCardRow({ card, onChange, onDelete }) {
return (
<div className="storycard">
<div className="row">
<input type="text" placeholder="Name (not sent to AI)" value={card.name}
onChange={(e) => onChange({ ...card, name: e.target.value })} />
<input type="text" placeholder="Type (e.g. Character)" value={card.type}
onChange={(e) => onChange({ ...card, type: e.target.value })} />
</div>
<div className="row">
<input type="text" placeholder="Triggers, comma-separated" value={card.keys}
onChange={(e) => onChange({ ...card, keys: e.target.value })} />
</div>
<textarea rows={2} placeholder="Entry — sent to the AI when a trigger matches" value={card.entry}
onChange={(e) => onChange({ ...card, entry: e.target.value })} />
<div style={{ textAlign: 'right', marginTop: 6 }}>
<button className="danger" style={{ padding: '3px 10px', fontSize: '0.78rem' }} onClick={onDelete}>
Remove
</button>
</div>
</div>
)
}
+677
View File
@@ -0,0 +1,677 @@
:root {
--bg: #0a0a0f;
--bg-panel: #131320;
--bg-panel-glass: rgba(19, 19, 32, 0.82);
--bg-input: #1a1a2a;
--border: #2b2b3d;
--border-bright: #3d3d55;
--text: #e2ddd0;
--text-dim: #918c7d;
--accent: #d4a94e;
--accent-bright: #e8c476;
--accent-dim: #96773a;
--accent-glow: rgba(212, 169, 78, 0.25);
--danger: #d06565;
--player: #9fc7d1;
--font-display: 'Cinzel', Georgia, serif;
--font-story: 'Crimson Pro', Georgia, 'Times New Roman', serif;
--font-ui: 'Inter', 'Segoe UI', system-ui, sans-serif;
font-family: var(--font-ui);
color-scheme: dark;
}
* { box-sizing: border-box; }
body {
margin: 0;
background:
radial-gradient(1200px 700px at 15% -10%, rgba(212, 169, 78, 0.06), transparent 60%),
radial-gradient(1000px 600px at 90% 110%, rgba(88, 76, 140, 0.08), transparent 55%),
var(--bg);
background-attachment: fixed;
color: var(--text);
min-height: 100vh;
}
#root { min-height: 100vh; display: flex; flex-direction: column; }
h1, h2, h3 { font-weight: 600; letter-spacing: 0.03em; }
a { color: var(--accent); text-decoration: none; transition: color 0.15s; }
a:hover { color: var(--accent-bright); }
::selection { background: var(--accent-dim); color: #0f0d08; }
/* ---------- Scrollbars ---------- */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border-bright);
border-radius: 5px;
border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); }
/* ---------- Top navigation ---------- */
.topnav {
display: flex;
align-items: center;
gap: 26px;
padding: 0 28px;
height: 56px;
background: var(--bg-panel-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
position: sticky;
top: 0;
z-index: 10;
}
.topnav .brand {
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.14em;
text-shadow: 0 0 18px var(--accent-glow);
margin-right: 6px;
}
.topnav a.navlink {
color: var(--text-dim);
padding: 17px 2px 15px;
font-size: 0.9rem;
font-weight: 500;
letter-spacing: 0.04em;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.2s;
}
.topnav a.navlink.active {
color: var(--accent-bright);
border-bottom-color: var(--accent);
text-shadow: 0 0 14px var(--accent-glow);
}
.topnav a.navlink:hover { color: var(--text); }
.page {
flex: 1;
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 28px 24px;
animation: page-in 0.35s ease both;
}
@keyframes page-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
/* ---------- Forms ---------- */
label.field { display: block; margin-bottom: 14px; }
label.field .label {
display: block;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-dim);
margin-bottom: 5px;
}
input[type="text"], input[type="password"], input[type="number"], textarea, select {
width: 100%;
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 12px;
font-size: 0.95rem;
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--accent-dim);
box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.12);
}
input::placeholder, textarea::placeholder { color: #5c584d; }
button {
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 7px 16px;
font-size: 0.88rem;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, transform 0.1s;
}
button:hover {
border-color: var(--accent-dim);
box-shadow: 0 0 12px rgba(212, 169, 78, 0.1);
}
button:active { transform: translateY(1px); }
button.primary {
background: linear-gradient(160deg, var(--accent-bright), var(--accent) 55%, #b78d3e);
border-color: var(--accent);
color: #191307;
font-weight: 600;
letter-spacing: 0.02em;
box-shadow: 0 2px 10px var(--accent-glow);
}
button.primary:hover {
box-shadow: 0 2px 18px rgba(212, 169, 78, 0.45);
filter: brightness(1.08);
}
button.danger { color: var(--danger); }
button.danger:hover { border-color: var(--danger); box-shadow: 0 0 12px rgba(208, 101, 101, 0.15); }
button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: none; }
/* ---------- Cards & lists ---------- */
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.card {
position: relative;
background: linear-gradient(170deg, #17172400, #17172400 60%, rgba(212, 169, 78, 0.03)), var(--bg-panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-dim), transparent);
opacity: 0;
transition: opacity 0.25s;
}
.card:hover {
border-color: var(--border-bright);
transform: translateY(-3px);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45), 0 0 20px rgba(212, 169, 78, 0.06);
}
.card:hover::before { opacity: 1; }
.card h3 {
margin: 0 0 6px;
font-family: var(--font-display);
font-size: 1.02rem;
color: var(--accent);
}
.card p { margin: 0; color: var(--text-dim); font-size: 0.88rem; line-height: 1.5; }
.card .meta { margin-top: 12px; font-size: 0.75rem; color: var(--text-dim); }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.page-header h1 {
margin: 0;
font-family: var(--font-display);
font-size: 1.55rem;
color: var(--accent-bright);
text-shadow: 0 0 24px var(--accent-glow);
}
.empty { color: var(--text-dim); text-align: center; padding: 60px 0; font-style: italic; font-family: var(--font-story); font-size: 1.05rem; }
/* ---------- Story card editor ---------- */
.storycard {
background: var(--bg-input);
border: 1px solid var(--border);
border-left: 3px solid var(--accent-dim);
border-radius: 8px;
padding: 12px;
margin-bottom: 10px;
transition: border-color 0.15s;
}
.storycard:focus-within { border-color: var(--accent-dim); }
.storycard .row { display: flex; gap: 10px; margin-bottom: 8px; }
.storycard .row > * { flex: 1; }
/* ---------- Play page ---------- */
.play-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
.story {
flex: 1;
font-family: var(--font-story);
font-size: 1.14rem;
line-height: 1.7;
white-space: pre-wrap;
padding-bottom: 16px;
}
.story .action {
position: relative;
margin-bottom: 15px;
padding-right: 56px;
animation: action-in 0.4s ease both;
}
@keyframes action-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.story .action.player {
color: var(--player);
border-left: 2px solid rgba(159, 199, 209, 0.35);
padding-left: 14px;
font-style: italic;
}
.story .action.streaming { color: var(--text); }
.story .cursor {
color: var(--accent-bright);
text-shadow: 0 0 8px var(--accent-glow);
animation: blink 1s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
/* Reasoning-model thinking, collapsed above the AI's response */
.story .reasoning {
margin: 0 0 8px;
font-size: 0.82rem;
line-height: 1.5;
color: var(--text-dim);
font-style: normal;
}
.story .reasoning summary {
cursor: pointer;
user-select: none;
color: var(--text-dim);
opacity: 0.8;
}
.story .reasoning summary:hover { opacity: 1; }
.story .reasoning .reasoning-text {
margin-top: 6px;
padding: 8px 12px;
border-left: 2px solid var(--border);
opacity: 0.85;
max-height: 260px;
overflow-y: auto;
}
.story .action-tools {
position: absolute;
top: 0;
right: 0;
display: inline-flex;
gap: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.story .action:hover .action-tools { opacity: 1; pointer-events: auto; }
.story .action-tools button {
padding: 1px 8px;
font-size: 0.72rem;
font-family: var(--font-ui);
color: var(--text-dim);
background: var(--bg-panel-glass);
backdrop-filter: blur(6px);
}
.story .action-tools button:hover { color: var(--text); }
.action-edit { margin-bottom: 14px; }
.action-edit textarea { min-height: 110px; font-family: inherit; font-size: inherit; }
.action-edit .edit-buttons { display: flex; gap: 8px; margin-top: 6px; }
.play-controls {
position: sticky;
bottom: 0;
background: linear-gradient(to top, var(--bg) 75%, transparent);
padding: 18px 0 16px;
border-top: 1px solid var(--border);
}
.turn-buttons { display: flex; gap: 8px; margin-bottom: 10px; }
.turn-buttons button { font-size: 0.78rem; padding: 4px 12px; color: var(--text-dim); }
.turn-buttons button:hover:not(:disabled) { color: var(--text); }
.input-bar { display: flex; gap: 8px; align-items: stretch; }
.input-bar input { flex: 1; }
.mode-select { display: flex; }
.mode-select button {
border-radius: 0;
border-right-width: 0;
padding: 7px 14px;
color: var(--text-dim);
text-transform: capitalize;
}
.mode-select button:first-child { border-radius: 8px 0 0 8px; }
.mode-select button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; }
.mode-select button.active {
color: var(--accent-bright);
background: var(--bg-panel);
border-color: var(--accent-dim);
border-right-width: 1px;
box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08);
}
.mode-select button.active + button { border-left-width: 0; }
/* ---------- Side panel (Plot / Insights) ---------- */
.play-layout { flex: 1; display: flex; min-height: calc(100vh - 56px); }
.play-layout .play-page { max-width: 960px; }
.play-layout.with-panel .play-page { max-width: none; }
.panel-toggles { display: flex; gap: 8px; }
.panel-toggles button.active {
color: var(--accent-bright);
border-color: var(--accent-dim);
box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08);
}
.side-panel {
width: 420px;
flex-shrink: 0;
border-left: 1px solid var(--border);
background: var(--bg-panel);
padding: 20px;
overflow-y: auto;
height: calc(100vh - 56px);
position: sticky;
top: 56px;
animation: panel-in 0.25s ease both;
}
@keyframes panel-in {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: none; }
}
.side-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.side-panel-header h2 {
margin: 0;
font-family: var(--font-display);
font-size: 1.05rem;
color: var(--accent);
}
/* ---------- Insights ---------- */
.insights-meta { margin-bottom: 16px; font-size: 0.85rem; }
.insights-mode { color: var(--text-dim); margin-bottom: 8px; }
.insights-mode button { padding: 2px 8px; font-size: 0.75rem; }
.token-total { font-size: 1rem; color: var(--text); margin-bottom: 4px; }
.token-total.over { color: var(--danger); }
.token-bar { height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; margin-bottom: 8px; }
.token-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-dim), var(--accent-bright));
box-shadow: 0 0 8px var(--accent-glow);
border-radius: 3px;
transition: width 0.4s ease;
}
.insights-history { color: var(--text-dim); margin-bottom: 8px; }
.insights-cards div { margin-bottom: 3px; }
.insights-cards .dropped { color: var(--danger); }
.ctx-section { margin-bottom: 12px; border-left: 3px solid var(--border); background: rgba(255, 255, 255, 0.015); border-radius: 0 6px 6px 0; }
.ctx-section .ctx-header {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
padding: 5px 10px 3px;
}
.ctx-section pre {
margin: 0;
padding: 6px 10px 8px;
white-space: pre-wrap;
word-break: break-word;
font-family: Consolas, monospace;
font-size: 0.8rem;
line-height: 1.5;
color: var(--text);
max-height: 260px;
overflow-y: auto;
}
.ctx-narrator, .ctx-ai_instructions { border-left-color: #7d8fc9; }
.ctx-narrator .ctx-header, .ctx-ai_instructions .ctx-header { color: #7d8fc9; }
.ctx-plot_essentials, .ctx-script_context { border-left-color: #c97dc0; }
.ctx-plot_essentials .ctx-header, .ctx-script_context .ctx-header { color: #c97dc0; }
.ctx-story_summary { border-left-color: #7dc9a2; }
.ctx-story_summary .ctx-header { color: #7dc9a2; }
.ctx-world_lore { border-left-color: #c9b47d; }
.ctx-world_lore .ctx-header { color: #c9b47d; }
.ctx-history, .ctx-recent_history { border-left-color: #6a6a78; }
.ctx-authors_note, .ctx-front_memory { border-left-color: #c97d7d; }
.ctx-authors_note .ctx-header, .ctx-front_memory .ctx-header { color: #c97d7d; }
/* ---------- Scripts ---------- */
.slot-tabs { display: flex; gap: 0; margin-bottom: 4px; }
.slot-tabs button {
border-radius: 0;
border-right-width: 0;
color: var(--text-dim);
}
.slot-tabs button:first-child { border-radius: 8px 0 0 8px; }
.slot-tabs button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; }
.slot-tabs button.active { color: var(--accent-bright); background: var(--bg-panel); }
.slot-hint { color: var(--text-dim); font-size: 0.8rem; margin-bottom: 8px; }
.cm-editor { border: 1px solid var(--border); border-radius: 8px; font-size: 0.88rem; }
.cm-editor.cm-focused {
outline: none;
border-color: var(--accent-dim);
box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.1);
}
.test-run .row { display: flex; gap: 12px; }
.test-result {
margin-top: 12px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
font-size: 0.85rem;
}
.test-result .label {
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.08em;
color: var(--text-dim);
margin-right: 6px;
}
.test-result pre {
display: inline-block;
margin: 2px 0;
white-space: pre-wrap;
word-break: break-word;
font-family: Consolas, monospace;
vertical-align: top;
}
.test-error { color: var(--danger); white-space: pre-wrap; }
.script-attach {
display: flex;
align-items: baseline;
gap: 8px;
padding: 9px 12px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 6px;
cursor: pointer;
transition: border-color 0.15s;
}
.script-attach:hover { border-color: var(--border-bright); }
.script-attach .dim { color: var(--text-dim); font-size: 0.82rem; }
/* ---------- Memory bank (Phase 6) ---------- */
.memory-row {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 12px;
margin-bottom: 6px;
}
.memory-row.forgotten { opacity: 0.55; }
.memory-text { font-size: 0.9rem; white-space: pre-wrap; }
.memory-meta {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 10px;
margin-top: 5px;
font-size: 0.78rem;
}
.memory-meta .dim { color: var(--text-dim); }
.memory-meta .action-tools { display: inline-flex; gap: 4px; margin-left: auto; }
.memory-meta .action-tools button {
padding: 1px 8px;
font-size: 0.72rem;
color: var(--text-dim);
}
.memory-meta .action-tools button:hover { color: var(--text); }
.memory-badge {
color: var(--accent);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0 8px;
}
.script-report { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 8px; }
.script-error { color: var(--danger); font-size: 0.85rem; margin-bottom: 4px; white-space: pre-wrap; }
.script-logs {
background: var(--bg-input);
border-radius: 8px;
padding: 8px 10px;
font-family: Consolas, monospace;
font-size: 0.8rem;
white-space: pre-wrap;
word-break: break-word;
margin: 6px 0;
}
/* ---------- Filter bar & tags ---------- */
.filter-bar { margin-bottom: 18px; }
.search-input { max-width: 340px; }
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.tag {
padding: 2px 12px;
font-size: 0.76rem;
border-radius: 999px;
color: var(--text-dim);
}
.tag.active {
color: var(--accent-bright);
border-color: var(--accent-dim);
box-shadow: inset 0 0 10px rgba(212, 169, 78, 0.1);
}
.tag.small {
display: inline-block;
background: var(--bg-input);
border: 1px solid var(--border);
padding: 1px 9px;
margin-right: 4px;
cursor: default;
}
/* ---------- Modal ---------- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(5, 5, 9, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
animation: page-in 0.2s ease both;
}
.modal {
width: min(480px, calc(100vw - 40px));
max-height: 80vh;
overflow-y: auto;
background: var(--bg-panel);
border: 1px solid var(--border-bright);
border-radius: 14px;
padding: 26px 28px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(212, 169, 78, 0.06);
}
.modal h2 {
margin: 0 0 4px;
font-family: var(--font-display);
font-size: 1.2rem;
color: var(--accent-bright);
}
.modal .modal-hint { margin: 0 0 18px; color: var(--text-dim); font-size: 0.88rem; }
.modal .modal-buttons { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
/* ---------- Debug log (Settings) ---------- */
.debug-entry {
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 8px;
overflow: hidden;
}
.debug-entry-head {
display: flex;
align-items: baseline;
gap: 12px;
padding: 9px 12px;
cursor: pointer;
font-size: 0.86rem;
background: var(--bg-input);
}
.debug-entry-head .dim, .debug-entry-body .dim { color: var(--text-dim); font-size: 0.8rem; }
.debug-status {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 1px 8px;
border-radius: 999px;
border: 1px solid var(--border-bright);
color: var(--text-dim);
}
.debug-status.ok { color: #7dc9a2; border-color: #7dc9a244; }
.debug-status.error { color: var(--danger); border-color: var(--danger); }
.debug-status.pending { color: var(--accent); border-color: var(--accent-dim); }
.debug-entry-body { padding: 10px 12px; font-size: 0.85rem; }
.debug-entry-body pre {
margin: 2px 0 8px;
padding: 8px 10px;
background: var(--bg-input);
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
font-family: Consolas, monospace;
font-size: 0.78rem;
max-height: 300px;
overflow-y: auto;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-panel-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--danger);
color: var(--text);
padding: 11px 20px;
border-radius: 10px;
z-index: 100;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
animation: toast-in 0.25s ease both;
}
.toast.ok { border-color: var(--accent); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow); }
@keyframes toast-in {
from { opacity: 0; transform: translate(-50%, 12px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
+34
View File
@@ -0,0 +1,34 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import App from './App.jsx'
import Home from './pages/Home.jsx'
import Scenarios from './pages/Scenarios.jsx'
import ScenarioEditor from './pages/ScenarioEditor.jsx'
import Play from './pages/Play.jsx'
import Scripts from './pages/Scripts.jsx'
import ScriptEditor from './pages/ScriptEditor.jsx'
import Settings from './pages/Settings.jsx'
import './index.css'
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{ index: true, element: <Home /> },
{ 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 /> },
],
},
])
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
)
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { downloadJSON, pickJSONFile } from '../components'
export default function Home() {
const [adventures, setAdventures] = useState(null)
const [search, setSearch] = useState('')
const navigate = useNavigate()
useEffect(() => {
api.listAdventures().then(setAdventures).catch(() => setAdventures([]))
}, [])
const visible = useMemo(() => {
if (!adventures) return null
const q = search.trim().toLowerCase()
if (!q) return adventures
return adventures.filter((a) =>
`${a.title} ${a.scenario_title || ''}`.toLowerCase().includes(q))
}, [adventures, search])
const remove = async (e, id) => {
e.stopPropagation()
if (!confirm('Delete this adventure permanently?')) return
await api.deleteAdventure(id)
setAdventures(adventures.filter((a) => a.id !== id))
}
const exportOne = async (e, adv) => {
e.stopPropagation()
const bundle = await api.exportAdventure(adv.id)
const safe = adv.title.replace(/[^\w-]+/g, '_').slice(0, 60) || 'adventure'
downloadJSON(bundle, `${safe}.json`)
}
const importOne = async () => {
try {
const bundle = await pickJSONFile()
const adv = await api.importAdventure(bundle)
navigate(`/play/${adv.id}`)
} catch (err) {
alert(err.message)
}
}
return (
<div className="page">
<div className="page-header">
<h1>Adventures</h1>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={importOne}>Import</button>
<button className="primary" onClick={() => navigate('/scenarios')}>
+ New Adventure
</button>
</div>
</div>
{adventures?.length > 0 && (
<div className="filter-bar">
<input
type="text"
className="search-input"
placeholder="Search adventures…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
)}
{visible === null ? null : visible.length === 0 ? (
<div className="empty">
{adventures.length === 0
? 'No adventures yet. Head to Scenarios to begin your first story.'
: 'No adventures match your search.'}
</div>
) : (
<div className="card-grid">
{visible.map((adv) => (
<div key={adv.id} className="card" onClick={() => navigate(`/play/${adv.id}`)}>
<h3>{adv.title}</h3>
<p>
{adv.scenario_title ? `From “${adv.scenario_title}” · ` : ''}
{adv.action_count} actions
</p>
<div className="meta">
Last played {new Date(adv.updated_at + 'Z').toLocaleString()}
<span style={{ float: 'right', display: 'inline-flex', gap: 4 }}>
<button
title="Export as JSON backup"
style={{ padding: '2px 8px', fontSize: '0.75rem' }}
onClick={(e) => exportOne(e, adv)}
>
Export
</button>
<button
className="danger"
style={{ padding: '2px 8px', fontSize: '0.75rem' }}
onClick={(e) => remove(e, adv.id)}
>
Delete
</button>
</span>
</div>
</div>
))}
</div>
)}
</div>
)
}
+706
View File
@@ -0,0 +1,706 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { Field, StoryCardRow } from '../components'
const MODES = ['do', 'say', 'story']
const PLAYER_TYPES = ['do', 'say', 'story']
// Models often emit light markdown emphasis; render **bold** / *italic*
// instead of showing raw asterisks. Everything else stays plain text.
function renderEmphasis(text) {
const re = /\*\*([^*\n]+)\*\*|\*([^*\n]+)\*/g
const parts = []
let last = 0
let match
while ((match = re.exec(text)) !== null) {
if (match.index > last) parts.push(text.slice(last, match.index))
parts.push(match[1] !== undefined
? <b key={match.index}>{match[1]}</b>
: <i key={match.index}>{match[2]}</i>)
last = match.index + match[0].length
}
if (parts.length === 0) return text
if (last < text.length) parts.push(text.slice(last))
return parts
}
function ReasoningBlock({ text, streaming }) {
if (!text) return null
return (
<details className="reasoning" open={streaming || undefined}>
<summary>💭 Reasoning{streaming ? '…' : ''}</summary>
<div className="reasoning-text">{text}</div>
</details>
)
}
const SECTION_LABELS = {
narrator: 'Narrator prompt',
script_context: 'Script context',
ai_instructions: 'AI Instructions',
plot_essentials: 'Plot Essentials',
story_summary: 'Story Summary',
used_memories: 'Used Memories (memory bank)',
world_lore: 'World Lore (story cards)',
history: 'Story history',
authors_note: "Author's Note",
recent_history: 'Recent history',
front_memory: 'Front memory',
}
function PlotPanel({ adventure, setAdventure }) {
const saveTimer = useRef(null)
const setField = (field, value) => {
setAdventure({ ...adventure, [field]: value })
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => api.updateAdventure(adventure.id, { [field]: value }), 600)
}
const addCard = async () => {
const card = await api.createStoryCard({ adventure_id: adventure.id })
setAdventure({ ...adventure, story_cards: [...adventure.story_cards, card] })
}
const updateCard = (card) => {
setAdventure({
...adventure,
story_cards: adventure.story_cards.map((c) => (c.id === card.id ? card : c)),
})
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => {
api.updateStoryCard(card.id, {
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
})
}, 600)
}
const deleteCard = async (cardId) => {
await api.deleteStoryCard(cardId)
setAdventure({
...adventure,
story_cards: adventure.story_cards.filter((c) => c.id !== cardId),
})
}
return (
<div>
<Field label="Plot Essentials (Memory)" value={adventure.memory}
onChange={(v) => setField('memory', v)} textarea
placeholder="Key facts the AI should always remember." />
<Field label="Author's Note" value={adventure.authors_note}
onChange={(v) => setField('authors_note', v)} textarea rows={2}
placeholder="Style/theme guidance, injected near the end of context." />
<Field label="AI Instructions" value={adventure.ai_instructions}
onChange={(v) => setField('ai_instructions', v)} textarea rows={2}
placeholder="Behavioral guidance for the model." />
<Field label="Story Summary" value={adventure.story_summary}
onChange={(v) => setField('story_summary', v)} textarea
placeholder="Running summary of events so far. Updated automatically every 15 actions when auto-summarization is on; your edits are kept as the base for the next update." />
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Story Cards</h3>
<button onClick={addCard}>+ Add</button>
</div>
{adventure.story_cards.length === 0 && (
<div className="empty" style={{ padding: '12px 0' }}>No story cards yet.</div>
)}
{adventure.story_cards.map((card) => (
<StoryCardRow key={card.id} card={card}
onChange={updateCard} onDelete={() => deleteCard(card.id)} />
))}
</div>
)
}
function MemoryRow({ memory, onChange, onDelete }) {
const [editText, setEditText] = useState(null)
const save = () => {
const text = editText.trim()
setEditText(null)
if (text && text !== memory.text) onChange({ text })
}
return (
<div className={`memory-row ${memory.forgotten ? 'forgotten' : ''}`}>
{editText !== null ? (
<div className="action-edit" style={{ margin: 0 }}>
<textarea autoFocus value={editText} rows={3}
onChange={(e) => setEditText(e.target.value)} />
<div className="edit-buttons">
<button className="primary" onClick={save}>Save</button>
<button onClick={() => setEditText(null)}>Cancel</button>
</div>
</div>
) : (
<>
<div className="memory-text">{memory.text}</div>
<div className="memory-meta">
{memory.pinned && <span className="memory-badge">📌 pinned</span>}
{memory.forgotten && <span className="memory-badge">forgotten</span>}
{!memory.embedded && !memory.forgotten && (
<span className="memory-badge" title="Embedded on the next turn">not embedded yet</span>
)}
{memory.source_start != null && (
<span className="dim">actions {memory.source_start}–{memory.source_end}</span>
)}
<span className="dim">used {memory.use_count}×</span>
<span className="action-tools">
<button title={memory.pinned ? 'Unpin' : 'Pin (always included in context)'}
onClick={() => onChange({ pinned: !memory.pinned })}>📌</button>
{memory.forgotten && (
<button title="Restore this memory"
onClick={() => onChange({ forgotten: false })}>↩</button>
)}
<button title="Edit" onClick={() => setEditText(memory.text)}>✎</button>
<button title="Delete" onClick={onDelete}>✕</button>
</span>
</div>
</>
)}
</div>
)
}
function MemoryPanel({ adventure, setAdventure, refreshKey }) {
const [memories, setMemories] = useState(null)
const [newText, setNewText] = useState('')
const load = useCallback(() => {
api.listMemories(adventure.id).then(setMemories).catch(() => setMemories([]))
}, [adventure.id])
// Summarization runs in the background after a turn, so also refresh shortly after.
useEffect(() => {
load()
const timer = setTimeout(load, 4000)
return () => clearTimeout(timer)
}, [load, refreshKey])
const setFlag = async (field, value) => {
setAdventure({ ...adventure, [field]: value })
await api.updateAdventure(adventure.id, { [field]: value })
}
const change = async (memory, data) => {
const updated = await api.updateMemory(adventure.id, memory.id, data)
setMemories((prev) => prev.map((m) => (m.id === memory.id ? updated : m)))
}
const remove = async (memory) => {
await api.deleteMemory(adventure.id, memory.id)
setMemories((prev) => prev.filter((m) => m.id !== memory.id))
}
const add = async () => {
const text = newText.trim()
if (!text) return
setNewText('')
const memory = await api.createMemory(adventure.id, text)
setMemories((prev) => [...prev, memory])
}
const active = memories?.filter((m) => !m.forgotten) ?? []
const forgotten = memories?.filter((m) => m.forgotten) ?? []
return (
<div>
<label className="script-attach">
<input type="checkbox" checked={adventure.auto_summarize}
onChange={(e) => setFlag('auto_summarize', e.target.checked)} />
<span>Auto-summarization</span>
<span className="dim">memories every 6 actions, Story Summary every 15</span>
</label>
<label className="script-attach">
<input type="checkbox" checked={adventure.memory_bank_enabled}
onChange={(e) => setFlag('memory_bank_enabled', e.target.checked)} />
<span>Memory Bank</span>
<span className="dim">retrieve relevant memories into context (needs an embedding model)</span>
</label>
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Memories {memories && `(${active.length})`}</h3>
<button onClick={load}>Refresh</button>
</div>
{!memories && <div className="empty" style={{ padding: '12px 0' }}>Loading…</div>}
{memories && active.length === 0 && (
<div className="empty" style={{ padding: '12px 0' }}>
No memories yet. They are generated automatically as you play (from action 12
onward), or add one below.
</div>
)}
{active.map((m) => (
<MemoryRow key={m.id} memory={m}
onChange={(data) => change(m, data)} onDelete={() => remove(m)} />
))}
<div className="input-bar" style={{ marginTop: 10 }}>
<input type="text" value={newText} placeholder="Add a memory manually…"
onChange={(e) => setNewText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') add() }} />
<button onClick={add}>+ Add</button>
</div>
{forgotten.length > 0 && (
<>
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Forgotten ({forgotten.length})</h3>
</div>
{forgotten.map((m) => (
<MemoryRow key={m.id} memory={m}
onChange={(data) => change(m, data)} onDelete={() => remove(m)} />
))}
</>
)}
</div>
)
}
function ScriptsPanel({ advId }) {
const [scripts, setScripts] = useState(null)
useEffect(() => {
api.listAdventureScripts(advId).then(setScripts).catch(() => setScripts([]))
}, [advId])
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)))
}
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) => (
<label key={s.id} className="script-attach">
<input type="checkbox" checked={s.enabled} onChange={() => toggle(s)} />
<span>{s.name}</span>
<span className="dim">{s.description}</span>
</label>
))}
</div>
)
}
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 ctx-script_context">
<div className="ctx-header"><span>Context before script</span></div>
<pre>{script.context_before}</pre>
</div>
<div className="ctx-section ctx-script_context">
<div className="ctx-header"><span>Context after script (sent to AI)</span></div>
<pre>{script.context_after}</pre>
</div>
</>
)}
</div>
)
}
function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
const [report, setReport] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
setError(null)
const load = inspectActionId
? api.getActionContext(advId, inspectActionId)
: api.getAdventureContext(advId)
load.then(setReport).catch((err) => { setReport(null); setError(err.message) })
}, [advId, inspectActionId, refreshKey])
if (error) return <div className="empty">{error}</div>
if (!report) return <div className="empty">Loading…</div>
const { tokens, cards, history, sections } = report
const overBudget = tokens.total > tokens.budget
return (
<div className="insights">
<div className="insights-meta">
{inspectActionId ? (
<div className="insights-mode">
Snapshot of a past turn
<button onClick={onClearInspect} style={{ marginLeft: 8 }}>Back to next turn</button>
</div>
) : (
<div className="insights-mode">What will be sent on the next turn</div>
)}
<div className={`token-total ${overBudget ? 'over' : ''}`}>
{tokens.total} / {tokens.budget} tokens
</div>
<div className="token-bar">
<div className="token-bar-fill"
style={{ width: `${Math.min(100, (tokens.total / tokens.budget) * 100)}%` }} />
</div>
<div className="insights-history">
History: {history.included} of {history.total} actions in context
{history.total > history.included && ' (older history trimmed)'}
{history.oldest_truncated && ' — oldest entry cut mid-text'}
</div>
{cards.length > 0 && (
<div className="insights-cards">
{cards.map((c, i) => (
<div key={i} className={c.included ? '' : 'dropped'}>
▸ <b>{c.name || '(unnamed card)'}</b> triggered on “{c.keyword}”
{!c.included && ' — dropped (over card budget)'}
</div>
))}
</div>
)}
{report.memories && (
<div className="insights-cards">
{report.memories.error && (
<div className="dropped">⚠ Memory bank: {report.memories.error}</div>
)}
{report.memories.used?.map((m, i) => (
<div key={i}>
▸ memory retrieved ({m.pinned ? 'pinned' : `similarity ${m.similarity.toFixed(2)}`}):
{' '}{m.text.length > 90 ? m.text.slice(0, 90) + '…' : m.text}
</div>
))}
{!report.memories.error && report.memories.used?.length === 0 && (
<div className="dim">Memory bank on — no memories retrieved.</div>
)}
</div>
)}
</div>
{sections.map((s, i) => (
<div key={i} className={`ctx-section ctx-${s.label}`}>
<div className="ctx-header">
<span>{SECTION_LABELS[s.label] || s.label}</span>
<span>{s.tokens} tok</span>
</div>
<pre>{s.text}</pre>
</div>
))}
<ScriptReport script={report.script} />
</div>
)
}
export default function Play() {
const { id } = useParams()
const navigate = useNavigate()
const [adventure, setAdventure] = useState(null)
const [actions, setActions] = useState([])
const [mode, setMode] = useState('do')
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(null)
const [reasoningStream, setReasoningStream] = useState(null)
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState(null)
const [editing, setEditing] = useState(null)
const [panel, setPanel] = useState(null) // null | 'plot' | 'insights'
const [inspectActionId, setInspectActionId] = useState(null)
const storyEndRef = useRef(null)
const abortRef = useRef(null)
const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom
useEffect(() => {
api.getAdventure(id)
.then((adv) => { setAdventure(adv); setActions(adv.actions) })
.catch(() => navigate('/'))
}, [id, navigate])
useEffect(() => {
const onScroll = () => {
pinnedRef.current =
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 120
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
useEffect(() => {
if (pinnedRef.current) {
storyEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}
}, [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])
} else if (event.type === 'chunk') {
setStreaming((prev) => (prev ?? '') + event.text)
} else if (event.type === 'reasoning') {
setReasoningStream((prev) => (prev ?? '') + event.text)
} else if (event.type === 'done') {
setStreaming(null)
setReasoningStream(null)
setActions((prev) => [...prev, event.action])
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()
abortRef.current = controller
setBusy(true)
setToast(null)
setStreaming('')
pinnedRef.current = true
try {
await run(controller.signal)
} catch (err) {
if (err.name === 'AbortError') {
setToast({ text: 'Generation stopped.', isError: false })
} else {
setToast({ text: err.message, isError: true })
}
} finally {
abortRef.current = null
setStreaming(null)
setReasoningStream(null)
setBusy(false)
}
}
function stopGeneration() {
abortRef.current?.abort()
}
function send(type = mode) {
const text = input.trim()
const payload = { type: text ? type : 'continue', text }
setInput('')
runTurn((signal) => api.sendAction(id, payload, handleEvent, signal))
}
function retry() {
setActions((prev) =>
prev.length && prev[prev.length - 1].type === 'ai' ? prev.slice(0, -1) : prev)
runTurn((signal) => api.retry(id, handleEvent, signal))
}
async function undo() {
setToast(null)
try {
setActions(await api.undo(id))
} catch (err) {
setToast({ text: err.message, isError: true })
}
}
// Ctrl+Z undo / Ctrl+R retry, ignored while typing in a field.
useEffect(() => {
const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai'
const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start'
const onKey = (e) => {
if (!(e.ctrlKey || e.metaKey) || busy) return
if (e.target.closest?.('input, textarea, select, [contenteditable]')) return
if (e.key.toLowerCase() === 'z' && canUndo) {
e.preventDefault()
undo()
} else if (e.key.toLowerCase() === 'r' && lastIsAi) {
e.preventDefault()
retry()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
})
async function saveEdit() {
const { id: actionId, text } = editing
setEditing(null)
try {
const updated = await api.updateAction(id, actionId, text)
setActions((prev) => prev.map((a) => (a.id === actionId ? updated : a)))
} catch (err) {
setToast({ text: err.message, isError: true })
}
}
async function removeAction(actionId) {
try {
await api.deleteAction(id, actionId)
setActions((prev) => prev.filter((a) => a.id !== actionId))
} catch (err) {
setToast({ text: err.message, isError: true })
}
}
function inspect(actionId) {
setInspectActionId(actionId)
setPanel('insights')
}
if (!adventure) return null
const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai'
const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start'
return (
<div className={`play-layout ${panel ? 'with-panel' : ''}`}>
<div className="page play-page">
<div className="page-header">
<h1>{adventure.title}</h1>
<div className="panel-toggles">
<button className={panel === 'plot' ? 'active' : ''}
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 === 'insights' ? 'active' : ''}
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
Insights
</button>
</div>
</div>
<div className="story">
{actions.length === 0 && streaming === null && (
<div className="empty">A blank page. Type something below to begin your story.</div>
)}
{actions.map((action) =>
editing?.id === action.id ? (
<div key={action.id} className="action-edit">
<textarea
autoFocus
value={editing.text}
onChange={(e) => setEditing({ ...editing, text: e.target.value })}
/>
<div className="edit-buttons">
<button className="primary" onClick={saveEdit}>Save</button>
<button onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
) : (
<div key={action.id}
className={`action ${PLAYER_TYPES.includes(action.type) ? 'player' : ''}`}>
<ReasoningBlock text={action.reasoning} />
{renderEmphasis(action.text)}
{!busy && (
<span className="action-tools">
{action.type === 'ai' && (
<button title="View the exact prompt that produced this"
onClick={() => inspect(action.id)}>🔍</button>
)}
<button title="Edit"
onClick={() => setEditing({ id: action.id, text: action.text })}>✎</button>
<button title="Delete" onClick={() => removeAction(action.id)}>✕</button>
</span>
)}
</div>
)
)}
{streaming !== null && (
<div className="action streaming">
<ReasoningBlock text={reasoningStream} streaming />
{renderEmphasis(streaming)}
<span className="cursor">▋</span>
</div>
)}
<div ref={storyEndRef} />
</div>
<div className="play-controls">
<div className="turn-buttons">
<button onClick={() => send('continue')} disabled={busy}>Continue ▸</button>
<button onClick={retry} disabled={busy || !lastIsAi} title="Ctrl+R">↻ Retry</button>
<button onClick={undo} disabled={busy || !canUndo} title="Ctrl+Z">↶ Undo</button>
</div>
<div className="input-bar">
<div className="mode-select">
{MODES.map((m) => (
<button key={m} className={mode === m ? 'active' : ''}
onClick={() => setMode(m)} disabled={busy}>
{m[0].toUpperCase() + m.slice(1)}
</button>
))}
</div>
<input
type="text"
value={input}
disabled={busy}
placeholder={
mode === 'do' ? 'What do you do?'
: mode === 'say' ? 'What do you say?'
: 'What happens next?'
}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !busy) send() }}
/>
{busy ? (
<button className="danger" onClick={stopGeneration}>■ Stop</button>
) : (
<button className="primary" onClick={() => send()}>Send</button>
)}
</div>
</div>
</div>
{panel && (
<div className="side-panel">
<div className="side-panel-header">
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', scripts: 'Scripts', insights: 'Insights' }[panel]}</h2>
<button onClick={() => setPanel(null)}>✕</button>
</div>
{panel === 'plot' ? (
<PlotPanel adventure={adventure} setAdventure={setAdventure} />
) : panel === 'memory' ? (
<MemoryPanel adventure={adventure} setAdventure={setAdventure}
refreshKey={actions.length} />
) : panel === 'scripts' ? (
<ScriptsPanel advId={id} />
) : (
<InsightsPanel advId={id} inspectActionId={inspectActionId}
onClearInspect={() => setInspectActionId(null)} refreshKey={actions.length} />
)}
</div>
)}
{toast && (
<div className={`toast ${toast.isError ? '' : 'ok'}`}>
{toast.text}
{toast.isError && (
<button style={{ marginLeft: 12 }} onClick={retry}>Retry</button>
)}
<button style={{ marginLeft: 8 }} onClick={() => setToast(null)}>✕</button>
</div>
)}
</div>
)
}
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { Field, StoryCardRow, downloadJSON } from '../components'
export default function ScenarioEditor() {
const { id } = useParams()
const navigate = useNavigate()
const [scenario, setScenario] = useState(null)
const [allScripts, setAllScripts] = useState([])
const [status, setStatus] = useState('')
const saveTimer = useRef(null)
useEffect(() => {
api.getScenario(id).then(setScenario).catch(() => navigate('/scenarios'))
api.listScripts().then(setAllScripts).catch(() => {})
}, [id, navigate])
const setField = (field, value) => {
const next = { ...scenario, [field]: value }
setScenario(next)
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(async () => {
await api.updateScenario(id, { [field]: value })
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
}, 600)
}
const addCard = async () => {
const card = await api.createStoryCard({ scenario_id: Number(id) })
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
}
const updateCard = (card) => {
setScenario({
...scenario,
story_cards: scenario.story_cards.map((c) => (c.id === card.id ? card : c)),
})
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => {
api.updateStoryCard(card.id, {
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
})
}, 600)
}
const deleteCard = async (cardId) => {
await api.deleteStoryCard(cardId)
setScenario({ ...scenario, story_cards: scenario.story_cards.filter((c) => c.id !== cardId) })
}
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`)
}
const deleteScenario = async () => {
if (!confirm('Delete this scenario permanently?')) return
await api.deleteScenario(id)
navigate('/scenarios')
}
const play = async () => {
const adv = await api.createAdventure({ scenario_id: Number(id) })
navigate(`/play/${adv.id}`)
}
if (!scenario) return null
return (
<div className="page">
<div className="page-header">
<h1>Edit Scenario</h1>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{status}</span>
<button onClick={exportScenario}>Export</button>
<button className="danger" onClick={deleteScenario}>Delete</button>
<button className="primary" onClick={play}>Play</button>
</div>
</div>
<Field label="Title" value={scenario.title} onChange={(v) => setField('title', v)} />
<Field label="Description" value={scenario.description} onChange={(v) => setField('description', v)}
textarea placeholder="Shown in the scenario list; not sent to the AI." />
<Field label="Opening Prompt" value={scenario.prompt} onChange={(v) => setField('prompt', v)}
textarea rows={6} placeholder="The opening story text. Supports ${placeholders} (Phase 5)." />
<Field label="Plot Essentials (Memory)" value={scenario.memory} onChange={(v) => setField('memory', v)}
textarea placeholder="Key facts the AI should always remember." />
<Field label="Author's Note" value={scenario.authors_note} onChange={(v) => setField('authors_note', v)}
textarea rows={2} placeholder="Style/theme guidance, injected near the end of context." />
<Field label="AI Instructions" value={scenario.ai_instructions} onChange={(v) => setField('ai_instructions', v)}
textarea rows={2} placeholder="Behavioral guidance for the model (always included)." />
<Field label="Tags" value={scenario.tags} onChange={(v) => setField('tags', v)} placeholder="fantasy, mystery" />
<div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Story Cards</h2>
<button onClick={addCard}>+ Add Card</button>
</div>
{scenario.story_cards.length === 0 && (
<div className="empty" style={{ padding: '20px 0' }}>
No story cards. Cards inject their Entry into context when a trigger word appears in the story.
</div>
)}
{scenario.story_cards.map((card) => (
<StoryCardRow key={card.id} card={card}
onChange={updateCard} onDelete={() => deleteCard(card.id)} />
))}
<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>
))
)}
</div>
)
}
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { extractPlaceholders, pickJSONFile, PlaceholderModal } from '../components'
function splitTags(tags) {
return (tags || '').split(',').map((t) => t.trim()).filter(Boolean)
}
export default function Scenarios() {
const [scenarios, setScenarios] = useState(null)
const [search, setSearch] = useState('')
const [tagFilter, setTagFilter] = useState(null)
const [pending, setPending] = useState(null) // { scenario, names } awaiting placeholder values
const navigate = useNavigate()
useEffect(() => {
api.listScenarios().then(setScenarios).catch(() => setScenarios([]))
}, [])
const allTags = useMemo(() => {
const tags = new Set()
for (const sc of scenarios || []) splitTags(sc.tags).forEach((t) => tags.add(t))
return [...tags].sort()
}, [scenarios])
const visible = useMemo(() => {
if (!scenarios) return null
const q = search.trim().toLowerCase()
return scenarios.filter((sc) => {
if (tagFilter && !splitTags(sc.tags).includes(tagFilter)) return false
if (q && !`${sc.title} ${sc.description} ${sc.tags}`.toLowerCase().includes(q)) return false
return true
})
}, [scenarios, search, tagFilter])
const createScenario = async () => {
const scenario = await api.createScenario({ title: 'New Scenario' })
navigate(`/scenarios/${scenario.id}`)
}
const begin = async (scenarioId, placeholders = {}) => {
const adv = await api.createAdventure({ scenario_id: scenarioId, placeholders })
navigate(`/play/${adv.id}`)
}
const startAdventure = async (e, scenarioId) => {
e.stopPropagation()
const scenario = await api.getScenario(scenarioId)
const names = extractPlaceholders(
scenario.prompt, scenario.memory, scenario.authors_note, scenario.ai_instructions,
...scenario.story_cards.map((c) => c.entry),
)
if (names.length === 0) return begin(scenarioId)
setPending({ scenario, names })
}
const startBlank = async () => {
const adv = await api.createAdventure({ title: 'Blank Adventure' })
navigate(`/play/${adv.id}`)
}
return (
<div className="page">
<div className="page-header">
<h1>Scenarios</h1>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={startBlank}>Blank Adventure</button>
<button onClick={async () => {
try {
const bundle = await pickJSONFile()
const { scenario, unmapped_keys } = await api.importScenario(bundle)
if (unmapped_keys.length) alert(`Imported. Unmapped fields ignored: ${unmapped_keys.join(', ')}`)
navigate(`/scenarios/${scenario.id}`)
} catch (err) { alert(err.message) }
}}>Import</button>
<button className="primary" onClick={createScenario}>+ New Scenario</button>
</div>
</div>
<div className="filter-bar">
<input
type="text"
className="search-input"
placeholder="Search scenarios…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{allTags.length > 0 && (
<div className="tag-row">
{allTags.map((tag) => (
<button
key={tag}
className={`tag ${tagFilter === tag ? 'active' : ''}`}
onClick={() => setTagFilter(tagFilter === tag ? null : tag)}
>
{tag}
</button>
))}
</div>
)}
</div>
{visible === null ? null : visible.length === 0 ? (
<div className="empty">
{scenarios.length === 0
? 'No scenarios yet. Create one to define a reusable story template.'
: 'No scenarios match your search.'}
</div>
) : (
<div className="card-grid">
{visible.map((sc) => (
<div key={sc.id} className="card" onClick={() => navigate(`/scenarios/${sc.id}`)}>
<h3>{sc.title}</h3>
<p>{sc.description || 'No description'}</p>
<div className="meta">
{splitTags(sc.tags).map((tag) => (
<span key={tag} className="tag small">{tag}</span>
))}
<button
className="primary"
style={{ float: 'right', padding: '3px 10px', fontSize: '0.78rem' }}
onClick={(e) => startAdventure(e, sc.id)}
>
Play
</button>
</div>
</div>
))}
</div>
)}
{pending && (
<PlaceholderModal
title={pending.scenario.title}
names={pending.names}
onCancel={() => setPending(null)}
onSubmit={(values) => { setPending(null); begin(pending.scenario.id, values) }}
/>
)}
</div>
)
}
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useRef, 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'
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 saveTimer = useRef(null)
// 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 }))
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(async () => {
await api.updateScript(id, { [field]: value })
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
}, 600)
}
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>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { downloadJSON, pickJSONFile } from '../components'
export default function Scripts() {
const [scripts, setScripts] = useState(null)
const navigate = useNavigate()
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) {
alert(err.message)
}
}
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>
)
}
+187
View File
@@ -0,0 +1,187 @@
import { useEffect, useState } from 'react'
import { api } from '../api'
function DebugLog() {
const [entries, setEntries] = useState(null)
const [openId, setOpenId] = useState(null)
const load = () => api.getDebugRequests().then(setEntries).catch(() => setEntries([]))
useEffect(() => { load() }, [])
if (!entries) return null
return (
<div className="debug-log">
<div className="page-header" style={{ marginTop: 28 }}>
<h3 style={{ margin: 0 }}>Recent AI requests</h3>
<button onClick={load}>Refresh</button>
</div>
{entries.length === 0 && (
<div className="empty" style={{ padding: '16px 0' }}>
No requests yet this session. Play a turn and refresh.
</div>
)}
{entries.map((e) => (
<div key={e.id} className="debug-entry">
<div className="debug-entry-head" onClick={() => setOpenId(openId === e.id ? null : e.id)}>
<span className={`debug-status ${e.status}`}>{e.status}</span>
<span>{e.model}</span>
<span className="dim">{new Date(e.time).toLocaleTimeString()}</span>
<span className="dim" style={{ marginLeft: 'auto' }}>{openId === e.id ? '▾' : '▸'}</span>
</div>
{openId === e.id && (
<div className="debug-entry-body">
<div className="dim">{e.url}</div>
{e.error && <div className="test-error">{e.error}</div>}
<div className="ctx-header" style={{ padding: '6px 0 2px' }}><span>Request</span></div>
<pre>{JSON.stringify(e.request, null, 2)}</pre>
<div className="ctx-header" style={{ padding: '6px 0 2px' }}><span>Response</span></div>
<pre>{e.response || '(empty)'}</pre>
</div>
)}
</div>
))}
</div>
)
}
export default function Settings() {
const [settings, setSettings] = useState(null)
const [testResult, setTestResult] = useState(null)
const [saved, setSaved] = useState('')
useEffect(() => {
api.getSettings().then(setSettings)
}, [])
const setField = (field, value) => setSettings({ ...settings, [field]: value })
const save = async () => {
await api.updateSettings(settings)
setSaved('Settings saved')
setTimeout(() => setSaved(''), 2000)
}
const test = async () => {
setTestResult({ pending: true })
await api.updateSettings(settings)
const result = await api.testConnection()
setTestResult(result)
}
if (!settings) return null
return (
<div className="page" style={{ maxWidth: 640 }}>
<div className="page-header">
<h1>Settings</h1>
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{saved}</span>
</div>
<label className="field">
<span className="label">Endpoint URL (OpenAI-compatible)</span>
<input type="text" value={settings.endpoint_url}
placeholder="http://localhost:11434/v1"
onChange={(e) => setField('endpoint_url', e.target.value)} />
</label>
<label className="field">
<span className="label">API Key</span>
<input type="password" value={settings.api_key}
placeholder="Leave empty for local endpoints"
onChange={(e) => setField('api_key', e.target.value)} />
</label>
<label className="field">
<span className="label">Model</span>
<input type="text" value={settings.model} placeholder="e.g. llama3.1"
onChange={(e) => setField('model', e.target.value)} />
</label>
<div style={{ display: 'flex', gap: 14 }}>
<label className="field" style={{ flex: 1 }}>
<span className="label">Temperature</span>
<input type="number" step="0.1" min="0" max="2" value={settings.temperature}
onChange={(e) => setField('temperature', Number(e.target.value))} />
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Max output tokens</span>
<input type="number" value={settings.max_output_tokens}
onChange={(e) => setField('max_output_tokens', Number(e.target.value))} />
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Context budget (tokens)</span>
<input type="number" value={settings.context_token_budget}
onChange={(e) => setField('context_token_budget', Number(e.target.value))} />
</label>
</div>
<label className="field">
<span className="label">Reasoning budget (tokens)</span>
<input type="number" min="0" value={settings.reasoning_max_tokens}
onChange={(e) => setField('reasoning_max_tokens', 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 = off (nothing extra
is sent — keep 0 for endpoints/models without reasoning support).
</span>
</label>
<label className="field">
<span className="label">API mode</span>
<select value={settings.api_mode} onChange={(e) => setField('api_mode', e.target.value)}>
<option value="chat">Chat (/v1/chat/completions)</option>
<option value="completion">Completion (/v1/completions)</option>
</select>
</label>
<div className="page-header" style={{ marginTop: 20 }}>
<h3 style={{ margin: 0 }}>Memory system</h3>
</div>
<div style={{ display: 'flex', gap: 14 }}>
<label className="field" style={{ flex: 1 }}>
<span className="label">Summarization model</span>
<input type="text" value={settings.summary_model}
placeholder="Empty = use main model"
onChange={(e) => setField('summary_model', e.target.value)} />
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Embedding model</span>
<input type="text" value={settings.embedding_model}
placeholder="e.g. nomic-embed-text (empty = memory bank off)"
onChange={(e) => setField('embedding_model', e.target.value)} />
</label>
</div>
<div style={{ display: 'flex', gap: 14 }}>
<label className="field" style={{ flex: 1 }}>
<span className="label">Memory bank capacity</span>
<input type="number" min="1" value={settings.memory_bank_capacity}
onChange={(e) => setField('memory_bank_capacity', Number(e.target.value))} />
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Memories per turn (top-K)</span>
<input type="number" min="1" value={settings.memory_top_k}
onChange={(e) => setField('memory_top_k', Number(e.target.value))} />
</label>
</div>
<label className="field">
<span className="label">Narrator system prompt</span>
<textarea rows={4} value={settings.narrator_prompt}
onChange={(e) => setField('narrator_prompt', e.target.value)} />
</label>
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>
<button className="primary" onClick={save}>Save</button>
<button onClick={test}>Test connection</button>
</div>
{testResult && (
<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.detail}
</div>
)}
<DebugLog />
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000',
},
},
})