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,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=Crimson+Pro:ital,wght@0,400;0,500;0,600;1,400&family=Inter:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>AI D&D</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1643
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@uiw/react-codemirror": "^4.25.10",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,24 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<nav className="topnav">
|
||||
<span className="brand">⚔ AI D&D</span>
|
||||
<NavLink to="/" end className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
||||
Adventures
|
||||
</NavLink>
|
||||
<NavLink to="/scenarios" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
||||
Scenarios
|
||||
</NavLink>
|
||||
<NavLink to="/scripts" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
||||
Scripts
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
||||
Settings
|
||||
</NavLink>
|
||||
</nav>
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
async function request(path, options = {}) {
|
||||
const resp = await fetch(`/api${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
if (!resp.ok) {
|
||||
let detail = resp.statusText
|
||||
try {
|
||||
const body = await resp.json()
|
||||
detail = body.detail || detail
|
||||
} catch { /* non-JSON error body */ }
|
||||
throw new Error(detail)
|
||||
}
|
||||
if (resp.status === 204) return null
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
// POSTs to an SSE endpoint and dispatches events: {type: 'player'|'chunk'|'done'|'error', ...}
|
||||
async function streamSSE(path, payload, onEvent, signal) {
|
||||
const resp = await fetch(`/api${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
})
|
||||
if (!resp.ok) {
|
||||
let detail = resp.statusText
|
||||
try { detail = (await resp.json()).detail || detail } catch { /* non-JSON */ }
|
||||
throw new Error(detail)
|
||||
}
|
||||
const reader = resp.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const events = buffer.split('\n\n')
|
||||
buffer = events.pop() // keep incomplete tail
|
||||
for (const block of events) {
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.startsWith('data:')) onEvent(JSON.parse(line.slice(5)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Scenarios
|
||||
listScenarios: () => request('/scenarios'),
|
||||
getScenario: (id) => request(`/scenarios/${id}`),
|
||||
createScenario: (data) => request('/scenarios', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateScenario: (id, data) => request(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteScenario: (id) => request(`/scenarios/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Adventures
|
||||
listAdventures: () => request('/adventures'),
|
||||
getAdventure: (id) => request(`/adventures/${id}`),
|
||||
createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }),
|
||||
updateAction: (advId, actionId, text) =>
|
||||
request(`/adventures/${advId}/actions/${actionId}`, { method: 'PATCH', body: JSON.stringify({ text }) }),
|
||||
deleteAction: (advId, actionId) =>
|
||||
request(`/adventures/${advId}/actions/${actionId}`, { method: 'DELETE' }),
|
||||
|
||||
sendAction: (advId, payload, handlers, signal) =>
|
||||
streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal),
|
||||
retry: (advId, handlers, signal) => streamSSE(`/adventures/${advId}/retry`, {}, handlers, signal),
|
||||
exportAdventure: (id) => request(`/adventures/${id}/export`),
|
||||
importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }),
|
||||
undo: (advId) => request(`/adventures/${advId}/undo`, { method: 'POST' }),
|
||||
getAdventureContext: (advId) => request(`/adventures/${advId}/context`),
|
||||
getActionContext: (advId, actionId) => request(`/adventures/${advId}/actions/${actionId}/context`),
|
||||
|
||||
// Memory bank
|
||||
listMemories: (advId) => request(`/adventures/${advId}/memories`),
|
||||
createMemory: (advId, text) =>
|
||||
request(`/adventures/${advId}/memories`, { method: 'POST', body: JSON.stringify({ text }) }),
|
||||
updateMemory: (advId, memoryId, data) =>
|
||||
request(`/adventures/${advId}/memories/${memoryId}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteMemory: (advId, memoryId) =>
|
||||
request(`/adventures/${advId}/memories/${memoryId}`, { method: 'DELETE' }),
|
||||
|
||||
listAdventureScripts: (advId) => request(`/adventures/${advId}/scripts`),
|
||||
updateAdventureScript: (advId, scriptId, data) =>
|
||||
request(`/adventures/${advId}/scripts/${scriptId}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
|
||||
// Scripts
|
||||
listScripts: () => request('/scripts'),
|
||||
getScript: (id) => request(`/scripts/${id}`),
|
||||
createScript: (data) => request('/scripts', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateScript: (id, data) => request(`/scripts/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteScript: (id) => request(`/scripts/${id}`, { method: 'DELETE' }),
|
||||
testScript: (id, data) => request(`/scripts/${id}/test`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
exportScript: (id) => request(`/scripts/${id}/export`),
|
||||
importScript: (bundle) => request('/scripts/import', { method: 'POST', body: JSON.stringify(bundle) }),
|
||||
|
||||
// Scenario import/export
|
||||
exportScenario: (id) => request(`/scenarios/${id}/export`),
|
||||
importScenario: (bundle) => request('/scenarios/import', { method: 'POST', body: JSON.stringify(bundle) }),
|
||||
|
||||
// Story cards
|
||||
createStoryCard: (data) => request('/story-cards', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateStoryCard: (id, data) => request(`/story-cards/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteStoryCard: (id) => request(`/story-cards/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Debug
|
||||
getDebugRequests: () => request('/debug/requests'),
|
||||
|
||||
// Settings
|
||||
getSettings: () => request('/settings'),
|
||||
updateSettings: (data) => request('/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
testConnection: () => request('/settings/test', { method: 'POST' }),
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export function downloadJSON(obj, filename) {
|
||||
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function pickJSONFile() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.json,application/json'
|
||||
input.onchange = () => {
|
||||
const file = input.files[0]
|
||||
if (!file) return reject(new Error('No file selected'))
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try { resolve(JSON.parse(reader.result)) }
|
||||
catch { reject(new Error('Not valid JSON')) }
|
||||
}
|
||||
reader.onerror = () => reject(new Error('Could not read file'))
|
||||
reader.readAsText(file)
|
||||
}
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
// Unique ${Placeholder} names, in order of first appearance, across the given texts.
|
||||
export function extractPlaceholders(...texts) {
|
||||
const names = []
|
||||
for (const text of texts) {
|
||||
for (const match of (text || '').matchAll(/\$\{([^}]+)\}/g)) {
|
||||
const name = match[1].trim()
|
||||
if (name && !names.includes(name)) names.push(name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
export function PlaceholderModal({ title, names, onSubmit, onCancel }) {
|
||||
const [values, setValues] = useState(Object.fromEntries(names.map((n) => [n, ''])))
|
||||
|
||||
const submit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h2>{title}</h2>
|
||||
<p className="modal-hint">This scenario asks a few questions before you begin.</p>
|
||||
{names.map((name, i) => (
|
||||
<label key={name} className="field">
|
||||
<span className="label">{name}</span>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus={i === 0}
|
||||
value={values[name]}
|
||||
onChange={(e) => setValues({ ...values, [name]: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<div className="modal-buttons">
|
||||
<button type="button" onClick={onCancel}>Cancel</button>
|
||||
<button type="submit" className="primary">Begin Adventure</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Field({ label, value, onChange, textarea, rows, placeholder }) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="label">{label}</span>
|
||||
{textarea ? (
|
||||
<textarea rows={rows || 3} value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
|
||||
) : (
|
||||
<input type="text" value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function StoryCardRow({ card, onChange, onDelete }) {
|
||||
return (
|
||||
<div className="storycard">
|
||||
<div className="row">
|
||||
<input type="text" placeholder="Name (not sent to AI)" value={card.name}
|
||||
onChange={(e) => onChange({ ...card, name: e.target.value })} />
|
||||
<input type="text" placeholder="Type (e.g. Character)" value={card.type}
|
||||
onChange={(e) => onChange({ ...card, type: e.target.value })} />
|
||||
</div>
|
||||
<div className="row">
|
||||
<input type="text" placeholder="Triggers, comma-separated" value={card.keys}
|
||||
onChange={(e) => onChange({ ...card, keys: e.target.value })} />
|
||||
</div>
|
||||
<textarea rows={2} placeholder="Entry — sent to the AI when a trigger matches" value={card.entry}
|
||||
onChange={(e) => onChange({ ...card, entry: e.target.value })} />
|
||||
<div style={{ textAlign: 'right', marginTop: 6 }}>
|
||||
<button className="danger" style={{ padding: '3px 10px', fontSize: '0.78rem' }} onClick={onDelete}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--bg-panel: #131320;
|
||||
--bg-panel-glass: rgba(19, 19, 32, 0.82);
|
||||
--bg-input: #1a1a2a;
|
||||
--border: #2b2b3d;
|
||||
--border-bright: #3d3d55;
|
||||
--text: #e2ddd0;
|
||||
--text-dim: #918c7d;
|
||||
--accent: #d4a94e;
|
||||
--accent-bright: #e8c476;
|
||||
--accent-dim: #96773a;
|
||||
--accent-glow: rgba(212, 169, 78, 0.25);
|
||||
--danger: #d06565;
|
||||
--player: #9fc7d1;
|
||||
--font-display: 'Cinzel', Georgia, serif;
|
||||
--font-story: 'Crimson Pro', Georgia, 'Times New Roman', serif;
|
||||
--font-ui: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||
font-family: var(--font-ui);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(1200px 700px at 15% -10%, rgba(212, 169, 78, 0.06), transparent 60%),
|
||||
radial-gradient(1000px 600px at 90% 110%, rgba(88, 76, 140, 0.08), transparent 55%),
|
||||
var(--bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
h1, h2, h3 { font-weight: 600; letter-spacing: 0.03em; }
|
||||
a { color: var(--accent); text-decoration: none; transition: color 0.15s; }
|
||||
a:hover { color: var(--accent-bright); }
|
||||
|
||||
::selection { background: var(--accent-dim); color: #0f0d08; }
|
||||
|
||||
/* ---------- Scrollbars ---------- */
|
||||
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-bright);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); }
|
||||
|
||||
/* ---------- Top navigation ---------- */
|
||||
|
||||
.topnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
padding: 0 28px;
|
||||
height: 56px;
|
||||
background: var(--bg-panel-glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.topnav .brand {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.14em;
|
||||
text-shadow: 0 0 18px var(--accent-glow);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.topnav a.navlink {
|
||||
color: var(--text-dim);
|
||||
padding: 17px 2px 15px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.2s;
|
||||
}
|
||||
.topnav a.navlink.active {
|
||||
color: var(--accent-bright);
|
||||
border-bottom-color: var(--accent);
|
||||
text-shadow: 0 0 14px var(--accent-glow);
|
||||
}
|
||||
.topnav a.navlink:hover { color: var(--text); }
|
||||
|
||||
.page {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px;
|
||||
animation: page-in 0.35s ease both;
|
||||
}
|
||||
@keyframes page-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* ---------- Forms ---------- */
|
||||
|
||||
label.field { display: block; margin-bottom: 14px; }
|
||||
label.field .label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
input[type="text"], input[type="password"], input[type="number"], textarea, select {
|
||||
width: 100%;
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-dim);
|
||||
box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.12);
|
||||
}
|
||||
input::placeholder, textarea::placeholder { color: #5c584d; }
|
||||
|
||||
button {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 7px 16px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, transform 0.1s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: var(--accent-dim);
|
||||
box-shadow: 0 0 12px rgba(212, 169, 78, 0.1);
|
||||
}
|
||||
button:active { transform: translateY(1px); }
|
||||
button.primary {
|
||||
background: linear-gradient(160deg, var(--accent-bright), var(--accent) 55%, #b78d3e);
|
||||
border-color: var(--accent);
|
||||
color: #191307;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
box-shadow: 0 2px 10px var(--accent-glow);
|
||||
}
|
||||
button.primary:hover {
|
||||
box-shadow: 0 2px 18px rgba(212, 169, 78, 0.45);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
button.danger { color: var(--danger); }
|
||||
button.danger:hover { border-color: var(--danger); box-shadow: 0 0 12px rgba(208, 101, 101, 0.15); }
|
||||
button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: none; }
|
||||
|
||||
/* ---------- Cards & lists ---------- */
|
||||
|
||||
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
|
||||
.card {
|
||||
position: relative;
|
||||
background: linear-gradient(170deg, #17172400, #17172400 60%, rgba(212, 169, 78, 0.03)), var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-dim), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--border-bright);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45), 0 0 20px rgba(212, 169, 78, 0.06);
|
||||
}
|
||||
.card:hover::before { opacity: 1; }
|
||||
.card h3 {
|
||||
margin: 0 0 6px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.02rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
.card p { margin: 0; color: var(--text-dim); font-size: 0.88rem; line-height: 1.5; }
|
||||
.card .meta { margin-top: 12px; font-size: 0.75rem; color: var(--text-dim); }
|
||||
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.55rem;
|
||||
color: var(--accent-bright);
|
||||
text-shadow: 0 0 24px var(--accent-glow);
|
||||
}
|
||||
|
||||
.empty { color: var(--text-dim); text-align: center; padding: 60px 0; font-style: italic; font-family: var(--font-story); font-size: 1.05rem; }
|
||||
|
||||
/* ---------- Story card editor ---------- */
|
||||
|
||||
.storycard {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent-dim);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.storycard:focus-within { border-color: var(--accent-dim); }
|
||||
.storycard .row { display: flex; gap: 10px; margin-bottom: 8px; }
|
||||
.storycard .row > * { flex: 1; }
|
||||
|
||||
/* ---------- Play page ---------- */
|
||||
|
||||
.play-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
|
||||
|
||||
.story {
|
||||
flex: 1;
|
||||
font-family: var(--font-story);
|
||||
font-size: 1.14rem;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.story .action {
|
||||
position: relative;
|
||||
margin-bottom: 15px;
|
||||
padding-right: 56px;
|
||||
animation: action-in 0.4s ease both;
|
||||
}
|
||||
@keyframes action-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.story .action.player {
|
||||
color: var(--player);
|
||||
border-left: 2px solid rgba(159, 199, 209, 0.35);
|
||||
padding-left: 14px;
|
||||
font-style: italic;
|
||||
}
|
||||
.story .action.streaming { color: var(--text); }
|
||||
.story .cursor {
|
||||
color: var(--accent-bright);
|
||||
text-shadow: 0 0 8px var(--accent-glow);
|
||||
animation: blink 1s steps(1) infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
/* Reasoning-model thinking, collapsed above the AI's response */
|
||||
.story .reasoning {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-dim);
|
||||
font-style: normal;
|
||||
}
|
||||
.story .reasoning summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.story .reasoning summary:hover { opacity: 1; }
|
||||
.story .reasoning .reasoning-text {
|
||||
margin-top: 6px;
|
||||
padding: 8px 12px;
|
||||
border-left: 2px solid var(--border);
|
||||
opacity: 0.85;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.story .action-tools {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.story .action:hover .action-tools { opacity: 1; pointer-events: auto; }
|
||||
.story .action-tools button {
|
||||
padding: 1px 8px;
|
||||
font-size: 0.72rem;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-dim);
|
||||
background: var(--bg-panel-glass);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.story .action-tools button:hover { color: var(--text); }
|
||||
|
||||
.action-edit { margin-bottom: 14px; }
|
||||
.action-edit textarea { min-height: 110px; font-family: inherit; font-size: inherit; }
|
||||
.action-edit .edit-buttons { display: flex; gap: 8px; margin-top: 6px; }
|
||||
|
||||
.play-controls {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, var(--bg) 75%, transparent);
|
||||
padding: 18px 0 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.turn-buttons { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||
.turn-buttons button { font-size: 0.78rem; padding: 4px 12px; color: var(--text-dim); }
|
||||
.turn-buttons button:hover:not(:disabled) { color: var(--text); }
|
||||
|
||||
.input-bar { display: flex; gap: 8px; align-items: stretch; }
|
||||
.input-bar input { flex: 1; }
|
||||
|
||||
.mode-select { display: flex; }
|
||||
.mode-select button {
|
||||
border-radius: 0;
|
||||
border-right-width: 0;
|
||||
padding: 7px 14px;
|
||||
color: var(--text-dim);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.mode-select button:first-child { border-radius: 8px 0 0 8px; }
|
||||
.mode-select button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; }
|
||||
.mode-select button.active {
|
||||
color: var(--accent-bright);
|
||||
background: var(--bg-panel);
|
||||
border-color: var(--accent-dim);
|
||||
border-right-width: 1px;
|
||||
box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08);
|
||||
}
|
||||
.mode-select button.active + button { border-left-width: 0; }
|
||||
|
||||
/* ---------- Side panel (Plot / Insights) ---------- */
|
||||
|
||||
.play-layout { flex: 1; display: flex; min-height: calc(100vh - 56px); }
|
||||
.play-layout .play-page { max-width: 960px; }
|
||||
.play-layout.with-panel .play-page { max-width: none; }
|
||||
|
||||
.panel-toggles { display: flex; gap: 8px; }
|
||||
.panel-toggles button.active {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--accent-dim);
|
||||
box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08);
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
width: 420px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
height: calc(100vh - 56px);
|
||||
position: sticky;
|
||||
top: 56px;
|
||||
animation: panel-in 0.25s ease both;
|
||||
}
|
||||
@keyframes panel-in {
|
||||
from { opacity: 0; transform: translateX(16px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.side-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.side-panel-header h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- Insights ---------- */
|
||||
|
||||
.insights-meta { margin-bottom: 16px; font-size: 0.85rem; }
|
||||
.insights-mode { color: var(--text-dim); margin-bottom: 8px; }
|
||||
.insights-mode button { padding: 2px 8px; font-size: 0.75rem; }
|
||||
.token-total { font-size: 1rem; color: var(--text); margin-bottom: 4px; }
|
||||
.token-total.over { color: var(--danger); }
|
||||
.token-bar { height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; margin-bottom: 8px; }
|
||||
.token-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-dim), var(--accent-bright));
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.insights-history { color: var(--text-dim); margin-bottom: 8px; }
|
||||
.insights-cards div { margin-bottom: 3px; }
|
||||
.insights-cards .dropped { color: var(--danger); }
|
||||
|
||||
.ctx-section { margin-bottom: 12px; border-left: 3px solid var(--border); background: rgba(255, 255, 255, 0.015); border-radius: 0 6px 6px 0; }
|
||||
.ctx-section .ctx-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim);
|
||||
padding: 5px 10px 3px;
|
||||
}
|
||||
.ctx-section pre {
|
||||
margin: 0;
|
||||
padding: 6px 10px 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ctx-narrator, .ctx-ai_instructions { border-left-color: #7d8fc9; }
|
||||
.ctx-narrator .ctx-header, .ctx-ai_instructions .ctx-header { color: #7d8fc9; }
|
||||
.ctx-plot_essentials, .ctx-script_context { border-left-color: #c97dc0; }
|
||||
.ctx-plot_essentials .ctx-header, .ctx-script_context .ctx-header { color: #c97dc0; }
|
||||
.ctx-story_summary { border-left-color: #7dc9a2; }
|
||||
.ctx-story_summary .ctx-header { color: #7dc9a2; }
|
||||
.ctx-world_lore { border-left-color: #c9b47d; }
|
||||
.ctx-world_lore .ctx-header { color: #c9b47d; }
|
||||
.ctx-history, .ctx-recent_history { border-left-color: #6a6a78; }
|
||||
.ctx-authors_note, .ctx-front_memory { border-left-color: #c97d7d; }
|
||||
.ctx-authors_note .ctx-header, .ctx-front_memory .ctx-header { color: #c97d7d; }
|
||||
|
||||
/* ---------- Scripts ---------- */
|
||||
|
||||
.slot-tabs { display: flex; gap: 0; margin-bottom: 4px; }
|
||||
.slot-tabs button {
|
||||
border-radius: 0;
|
||||
border-right-width: 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.slot-tabs button:first-child { border-radius: 8px 0 0 8px; }
|
||||
.slot-tabs button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; }
|
||||
.slot-tabs button.active { color: var(--accent-bright); background: var(--bg-panel); }
|
||||
.slot-hint { color: var(--text-dim); font-size: 0.8rem; margin-bottom: 8px; }
|
||||
|
||||
.cm-editor { border: 1px solid var(--border); border-radius: 8px; font-size: 0.88rem; }
|
||||
.cm-editor.cm-focused {
|
||||
outline: none;
|
||||
border-color: var(--accent-dim);
|
||||
box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.1);
|
||||
}
|
||||
|
||||
.test-run .row { display: flex; gap: 12px; }
|
||||
.test-result {
|
||||
margin-top: 12px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.test-result .label {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.test-result pre {
|
||||
display: inline-block;
|
||||
margin: 2px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: Consolas, monospace;
|
||||
vertical-align: top;
|
||||
}
|
||||
.test-error { color: var(--danger); white-space: pre-wrap; }
|
||||
|
||||
.script-attach {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.script-attach:hover { border-color: var(--border-bright); }
|
||||
.script-attach .dim { color: var(--text-dim); font-size: 0.82rem; }
|
||||
|
||||
/* ---------- Memory bank (Phase 6) ---------- */
|
||||
|
||||
.memory-row {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.memory-row.forgotten { opacity: 0.55; }
|
||||
.memory-text { font-size: 0.9rem; white-space: pre-wrap; }
|
||||
.memory-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 5px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.memory-meta .dim { color: var(--text-dim); }
|
||||
.memory-meta .action-tools { display: inline-flex; gap: 4px; margin-left: auto; }
|
||||
.memory-meta .action-tools button {
|
||||
padding: 1px 8px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.memory-meta .action-tools button:hover { color: var(--text); }
|
||||
.memory-badge {
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.script-report { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 8px; }
|
||||
.script-error { color: var(--danger); font-size: 0.85rem; margin-bottom: 4px; white-space: pre-wrap; }
|
||||
.script-logs {
|
||||
background: var(--bg-input);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* ---------- Filter bar & tags ---------- */
|
||||
|
||||
.filter-bar { margin-bottom: 18px; }
|
||||
.search-input { max-width: 340px; }
|
||||
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.tag {
|
||||
padding: 2px 12px;
|
||||
font-size: 0.76rem;
|
||||
border-radius: 999px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.tag.active {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--accent-dim);
|
||||
box-shadow: inset 0 0 10px rgba(212, 169, 78, 0.1);
|
||||
}
|
||||
.tag.small {
|
||||
display: inline-block;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1px 9px;
|
||||
margin-right: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- Modal ---------- */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(5, 5, 9, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
animation: page-in 0.2s ease both;
|
||||
}
|
||||
.modal {
|
||||
width: min(480px, calc(100vw - 40px));
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-bright);
|
||||
border-radius: 14px;
|
||||
padding: 26px 28px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(212, 169, 78, 0.06);
|
||||
}
|
||||
.modal h2 {
|
||||
margin: 0 0 4px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.2rem;
|
||||
color: var(--accent-bright);
|
||||
}
|
||||
.modal .modal-hint { margin: 0 0 18px; color: var(--text-dim); font-size: 0.88rem; }
|
||||
.modal .modal-buttons { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
|
||||
|
||||
/* ---------- Debug log (Settings) ---------- */
|
||||
|
||||
.debug-entry {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.debug-entry-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.86rem;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.debug-entry-head .dim, .debug-entry-body .dim { color: var(--text-dim); font-size: 0.8rem; }
|
||||
.debug-status {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-bright);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.debug-status.ok { color: #7dc9a2; border-color: #7dc9a244; }
|
||||
.debug-status.error { color: var(--danger); border-color: var(--danger); }
|
||||
.debug-status.pending { color: var(--accent); border-color: var(--accent-dim); }
|
||||
.debug-entry-body { padding: 10px 12px; font-size: 0.85rem; }
|
||||
.debug-entry-body pre {
|
||||
margin: 2px 0 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 0.78rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ---------- Toast ---------- */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-panel-glass);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--text);
|
||||
padding: 11px 20px;
|
||||
border-radius: 10px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
|
||||
animation: toast-in 0.25s ease both;
|
||||
}
|
||||
.toast.ok { border-color: var(--accent); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow); }
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translate(-50%, 12px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import Home from './pages/Home.jsx'
|
||||
import Scenarios from './pages/Scenarios.jsx'
|
||||
import ScenarioEditor from './pages/ScenarioEditor.jsx'
|
||||
import Play from './pages/Play.jsx'
|
||||
import Scripts from './pages/Scripts.jsx'
|
||||
import ScriptEditor from './pages/ScriptEditor.jsx'
|
||||
import Settings from './pages/Settings.jsx'
|
||||
import './index.css'
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <App />,
|
||||
children: [
|
||||
{ index: true, element: <Home /> },
|
||||
{ path: 'scenarios', element: <Scenarios /> },
|
||||
{ path: 'scenarios/:id', element: <ScenarioEditor /> },
|
||||
{ path: 'play/:id', element: <Play /> },
|
||||
{ path: 'scripts', element: <Scripts /> },
|
||||
{ path: 'scripts/:id', element: <ScriptEditor /> },
|
||||
{ path: 'settings', element: <Settings /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user