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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user