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
+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>
)
}