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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFsGHju9szibJJa2YJcdbg
This commit is contained in:
parththakkar106
2026-07-06 16:08:19 +05:30
co-authored by Claude Fable 5
commit db9f904222
57 changed files with 7804 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# Secrets
*.env
backend/openrouter_key.env
# Python
backend/.venv/
__pycache__/
*.pyc
# Database
*.db
# Node
node_modules/
frontend/dist/
# Misc
.claude/
+131
View File
@@ -0,0 +1,131 @@
# Code Review Findings — 2026-07-05
Full-codebase review (no git history, so whole project was the scope).
Status: `pending` = not yet fixed, `fixed` = applied, `verify-failed` = finding was wrong on closer look, `skipped` = intentionally not fixed.
Resume point: fix `pending` items top-to-bottom (they are ranked by severity).
## Correctness bugs
### 1. [pending] seed_demo.py doesn't stamp schema version → server crashes on next start
- `backend/seed_demo.py:10`
- Fresh DB created via `Base.metadata.create_all` leaves `PRAGMA user_version` at 0. Next server start sees tables exist, replays every ALTER TABLE migration → `duplicate column name` crash.
- Fix: stamp user_version to latest after create_all (reuse migrations.bootstrap logic).
### 2. [pending] Turn-lock race: two simultaneous turns can run on the same adventure
- `backend/app/routers/adventures.py:302`
- `ensure_not_generating()` runs in the route handler but `_active_turns.add()` only happens when the StreamingResponse generator is first iterated. Double-click Continue → both requests pass the 409 check → duplicate Action.index rows, interleaved generations.
- Fix: atomically test-and-set the lock in the request phase, release in the stream's `finally`.
### 3. [pending] Migration 10 renumbers indexes with a correlated subquery on the table being updated
- `backend/app/migrations.py:34`
- SQLite may evaluate the subquery against partially-updated rows → duplicate indexes survive the "repair".
- Fix: compute new indexes in Python (SELECT ordered, then UPDATE per row).
### 4. [pending] SQLite foreign keys never enabled → CASCADE/SET NULL clauses are dead
- `backend/app/database.py:8`
- Deleting a Script leaves orphaned `scenario_scripts` rows; SQLite rowid reuse can attach a future script to an old scenario.
- Fix: `PRAGMA foreign_keys=ON` via engine connect event.
### 5. [pending] Provider generate() silently yields nothing for non-SSE 200 responses
- `backend/app/providers/openai_compatible.py:84`
- Server that ignores `stream=true` and returns plain JSON → no `data:` lines → empty AI action, no error.
- Fix: buffer non-SSE body and fall back to parsing it as a single JSON completion.
### 6. [pending] Fire-and-forget asyncio task can be GC'd mid-run and wedge the memory bank
- `backend/app/memorybank.py:146`
- `asyncio.create_task` result not referenced; task can vanish silently; adventure ID can stay stuck in `_running`.
- Fix: keep strong refs in a set, discard in done-callback.
### 7. [pending] Memory cursors are list positions but Memory.source_start/end are Action.index values
- `backend/app/memorybank.py:213`
- After any action deletion (indexes keep gaps, positions shift), summarization skips/duplicates blocks and `_update_story_summary` folds the wrong memories.
- Fix: use one space consistently — track cursors by Action.index (position-independent), or renumber on delete.
### 8. [pending] Pinned memories don't count toward memory_top_k cap
- `backend/app/memorybank.py:120`
- 6 pinned + top_k=5 → 11 memories injected, blowing token budget.
- Fix: fill with unpinned only up to `top_k - len(pinned)` (min 0).
### 9. [pending] Embedding-model change → cosine() zips different-dimension vectors silently
- `backend/app/memorybank.py:69`
- Old 768-dim embeddings scored against new 1536-dim query → garbage similarity, no error, never re-embedded.
- Fix: return 0.0 on length mismatch (and ideally clear stale embeddings so _embed_pending redoes them).
### 10. [pending] MAX_STORY_CARDS cap is a no-op for cards created in one hook
- `backend/app/scripting/pipeline.py:59`
- `len(existing) + len(seen_ids) < MAX...` never counts newly added cards (seen_ids ⊂ existing). A script can insert unbounded cards in one turn.
- Fix: count inserts made during the loop.
### 11. [pending] addStoryCard returns 0 (falsy) for the first card, indistinguishable from `false` rejection
- `backend/app/scripting/engine.py:39`
- `if (!addStoryCard(...))` misfires when the card list was empty.
- Fix: return `storyCards.length` (1-based, always truthy) or `true`; document.
### 12. [pending] scenario_id=0 truthiness bug in create_story_card
- `backend/app/routers/story_cards.py:28`
- `scenario_id or ...` picks the wrong owner when id is 0. Use `is not None`.
### 13. [pending] test_connection 500s on non-dict JSON from /models
- `backend/app/routers/settings.py:54`
- Only ValueError caught; `data.get`/`m.get` on non-dict raises AttributeError → 500 instead of `{ok:false}`.
- Fix: catch (ValueError, AttributeError, TypeError) or validate shapes.
### 14. [pending] AI Dungeon exports with `worldInformation` key lose all story cards silently
- `backend/app/routers/scenarios.py:133`
- Import reads only `storyCards`/`worldInfo`; `worldInformation` is in _IGNORED_KEYS so it's dropped and not reported.
- Fix: accept `worldInformation` as a card source too.
### 15. [pending] Shared debounce timer loses edits (Play PlotPanel)
- `frontend/src/pages/Play.jsx:28`
- One `saveTimer` shared by all plot fields AND story-card saves; editing a second thing within 600ms cancels the first pending PATCH → silent data loss.
- Fix: per-key timers (e.g. a Map keyed by field/card id).
### 16. [pending] Same shared-debounce data loss in ScenarioEditor
- `frontend/src/pages/ScenarioEditor.jsx:22`
- Same fix as #15.
### 17. [pending] Continue button silently discards typed input text
- `frontend/src/pages/Play.jsx:469`
- Clicking Continue with text in the box sends type 'continue' (backend ignores text) and clears the input.
- Fix: don't clear input on continue (or treat non-empty input as a normal send).
### 18. [pending] retry() optimistically deletes last AI action with no rollback on failure
- `frontend/src/pages/Play.jsx:475`
- Failed retry (409/network) leaves UI missing an action that still exists server-side.
- Fix: restore the removed action in the catch path (or only remove on first stream event).
### 19. [pending] Settings test()/save() have no error handling → stuck on "Testing…"
- `frontend/src/pages/Settings.jsx:66`
- Rejection leaves `{pending:true}` forever + unhandled rejection.
- Fix: try/catch → setTestResult({ok:false, error:msg}).
### 20. [pending] InsightsPanel race: slow earlier request overwrites newer report
- `frontend/src/pages/Play.jsx:302`
- No staleness guard; slow getAdventureContext can clobber a newer action snapshot.
- Fix: track a request id / cancelled flag in the effect.
### 21. [pending] extractPlaceholders ignores ${...} in story-card trigger keys
- `frontend/src/pages/Scenarios.jsx:50`
- Backend fills placeholders in card.keys but the modal never prompts for those names → literal `${hero}` keys never match.
- Fix: also scan card.keys when collecting placeholder names.
## Cleanup backlog (reuse / simplification / efficiency / altitude — not bugs, apply later)
- **R1** `frontend/src/pages/Play.jsx:26` + `ScenarioEditor.jsx:19` + `ScriptEditor.jsx:34` — three copies of the debounced-autosave + story-card handlers; extract a `useDebouncedSave` hook / shared StoryCardList component. (Fixing bugs #15/#16 properly may accomplish this.)
- **R2** `backend/seed_demo.py:228` — re-implements create_adventure; call the router logic instead.
- **R3** `backend/app/providers/openai_compatible.py:122` — complete() duplicates _request() body building; add a `stream` param to _request().
- **R4** `backend/app/routers/adventures.py:476` — six copies of child-resource get+owner-check+404; extract `get_owned_or_404`.
- **R5** `frontend/src/api.js:26` — streamSSE duplicates request()'s error extraction; extract `throwIfNotOk(resp)`.
- **S1** `backend/app/models.py:210` — `Settings.stream` is dead state (never read); delete column + schema fields.
- **S2** `frontend/src/pages/Play.jsx:6` — MODES and PLAYER_TYPES are identical constants; lastIsAi/canUndo computed twice.
- **E1** `backend/app/context/builder.py:119` — joins+tokenizes the ENTIRE adventure history every turn for the trigger window; walk reversed(actions) until budget instead.
- **E2** `backend/app/scripting/pipeline.py:77` — rebuilds full history dicts + JSON + a blocking commit per script per hook; build once per hook, slice to HISTORY_WINDOW first, commit once.
- **E3** `frontend/src/pages/Play.jsx:561` — every SSE chunk re-renders all action rows; isolate streaming text in a child component / React.memo rows.
- **E4** `backend/app/context/builder.py:48` — Section.tokens uncached, whole context tokenized 2-3×/turn; cache counts, sum sections.
- **E5** `frontend/src/pages/Play.jsx:490` — keydown effect has no dep array → listener re-registered every render.
- **E6** `backend/app/memorybank.py:182` — catch-up summarization awaits blocks sequentially; gather independent blocks.
- **A1** `backend/app/models.py:145` — no UniqueConstraint('adventure_id','index'); index allocation is ad-hoc per writer. (Related to bug #2.)
- **A2** `backend/app/providers/openai_compatible.py:45` — CHAT_CONTINUE_HINT appended below the budgeting layer; assemble prompts in context builder.
- **A3** `backend/app/routers/adventures.py:390` — import endpoints hand-coerce raw dicts; use a Pydantic bundle schema.
- **A4** `backend/app/routers/adventures.py:207` — onModelContext flattens (system, story) and ships everything as user content if modified; pass structure through the hook.
- **A5** `frontend/src/pages/Home.jsx:87` — client appends 'Z' to naive datetimes; emit ISO-8601 with offset from the API instead.
+54
View File
@@ -0,0 +1,54 @@
# AI D&D — local AI Dungeon-style storytelling app
Python (FastAPI + SQLite) backend, React (Vite) frontend, connectable to any
OpenAI-compatible AI endpoint (Ollama, LM Studio, OpenAI, OpenRouter, …).
Plan: see [`plan/00-OVERVIEW.md`](plan/00-OVERVIEW.md). Currently completed: **Phase 4** (AI Dungeon-compatible scripting).
## Run
```powershell
.\start.ps1
```
Then open http://localhost:5173. Backend API docs at http://localhost:8000/docs.
Or manually, in two terminals:
```powershell
# Terminal 1 — backend
cd backend
.\.venv\Scripts\uvicorn.exe app.main:app --port 8000 --reload
# Terminal 2 — frontend
cd frontend
npm run dev
```
## First-time setup
```powershell
cd backend
python -m venv .venv
.\.venv\Scripts\pip.exe install -r requirements.txt
cd ..\frontend
npm install
```
## Production-ish serving (single port)
Build the frontend, then the backend serves it statically:
```powershell
cd frontend
npm run build
# then run the backend and open http://localhost:8000
```
## Layout
- `backend/app/` — FastAPI app: `models.py` (SQLAlchemy), `schemas.py` (Pydantic), `routers/`
- `backend/data.db` — SQLite database (created on first run)
- `frontend/src/` — React SPA: `pages/`, `api.js`
- `plan/` — phased implementation plan
View File
+3
View File
@@ -0,0 +1,3 @@
from .builder import build_context, count_tokens, truncate_to_last_tokens
__all__ = ["build_context", "count_tokens", "truncate_to_last_tokens"]
+206
View File
@@ -0,0 +1,206 @@
"""Context assembly per AI Dungeon's memory system
(help.aidungeon.com/faq/the-memory-system):
[AI Instructions] always included
[Plot Essentials] always included (classic "Memory")
[Story Summary] always included (manual in Phase 3, auto in Phase 6)
[Used Memories] top-K memory-bank retrievals (Phase 6, when enabled)
[Triggered Story Cards] "World Lore: <entry>", conditional; first dropped when over budget
[Story history] newest actions that fit the remaining token budget
[Author's Note] injected AUTHORS_NOTE_DEPTH actions before the end of history
[Latest player action] (+ script frontMemory right after it, Phase 4)
"""
import functools
from dataclasses import dataclass
import tiktoken
from .. import models
AUTHORS_NOTE_DEPTH = 3 # actions from the end of history
CARD_BUDGET_SHARE = 0.4 # max share of non-reserved budget that story cards may take
SEPARATOR = "\n\n"
@functools.lru_cache(maxsize=1)
def _encoding() -> tiktoken.Encoding:
return tiktoken.get_encoding("cl100k_base")
def count_tokens(text: str) -> int:
return len(_encoding().encode(text))
def truncate_to_last_tokens(text: str, budget: int) -> str:
tokens = _encoding().encode(text)
if len(tokens) <= budget:
return text
return _encoding().decode(tokens[-budget:])
@dataclass
class Section:
label: str
text: str
@property
def tokens(self) -> int:
return count_tokens(self.text)
def _script_memory(adventure: models.Adventure) -> dict:
"""Script-provided memory overrides (populated by Phase 4 scripting)."""
state = adventure.script_state if isinstance(adventure.script_state, dict) else {}
memory = state.get("memory")
return memory if isinstance(memory, dict) else {}
def _match_cards(cards: list[models.StoryCard], window_text: str) -> list[dict]:
"""AI Dungeon trigger rules: case-insensitive, space-sensitive, partial-word
('boat' triggers on 'boats'). Returns one record per card with the keyword that fired."""
haystack = window_text.lower()
matched = []
for card in cards:
for key in (k.strip().lower() for k in card.keys.split(",")):
if key and key in haystack:
matched.append(
{"id": card.id, "name": card.name, "keyword": key, "entry": card.entry}
)
break
return matched
def build_context(
adventure: models.Adventure,
settings: models.Settings,
memory_bank: dict | None = None,
) -> tuple[str, str, dict]:
"""Returns (system_text, story_text, context_report). `memory_bank` is the
result of memorybank.retrieve_memories (None when the bank is off)."""
script_mem = _script_memory(adventure)
# ----- Always-included components -----
system_sections: list[Section] = [Section("narrator", settings.narrator_prompt.strip())]
if isinstance(script_mem.get("context"), str) and script_mem["context"].strip():
system_sections.append(Section("script_context", script_mem["context"].strip()))
if adventure.ai_instructions.strip():
system_sections.append(Section("ai_instructions", adventure.ai_instructions.strip()))
if adventure.memory.strip():
system_sections.append(
Section("plot_essentials", f"Plot essentials:\n{adventure.memory.strip()}")
)
if adventure.story_summary.strip():
system_sections.append(
Section("story_summary", f"Story summary:\n{adventure.story_summary.strip()}")
)
if memory_bank and memory_bank.get("used"):
lines = "\n".join(f"- {m['text']}" for m in memory_bank["used"])
system_sections.append(Section("used_memories", f"Memories:\n{lines}"))
authors_note_text = adventure.authors_note.strip()
if isinstance(script_mem.get("authorsNote"), str) and script_mem["authorsNote"].strip():
authors_note_text = script_mem["authorsNote"].strip()
authors_note = f"[Author's note: {authors_note_text}]" if authors_note_text else ""
front_memory = ""
if isinstance(script_mem.get("frontMemory"), str):
front_memory = script_mem["frontMemory"].strip()
reserved = (
sum(s.tokens for s in system_sections)
+ count_tokens(authors_note)
+ count_tokens(front_memory)
)
available = max(256, settings.context_token_budget - reserved)
# ----- Story cards: triggered by recent story text (the window history could fill) -----
actions = [a for a in adventure.actions if a.text.strip()]
trigger_window = truncate_to_last_tokens(SEPARATOR.join(a.text for a in actions), available)
triggered = _match_cards(adventure.story_cards, trigger_window)
card_budget = int(available * CARD_BUDGET_SHARE)
card_records = []
lore_lines: list[str] = []
used = 0
for match in triggered:
line = f"World Lore: {match['entry'].strip()}"
tokens = count_tokens(line)
included = used + tokens <= card_budget
if included:
lore_lines.append(line)
used += tokens
card_records.append(
{"id": match["id"], "name": match["name"], "keyword": match["keyword"],
"included": included}
)
if lore_lines:
system_sections.append(Section("world_lore", "\n".join(lore_lines)))
# ----- Story history: newest first until the remaining budget is spent -----
history_budget = available - used
included_actions: list[models.Action] = []
spent = 0
oldest_truncated = False
for action in reversed(actions):
tokens = count_tokens(action.text) + count_tokens(SEPARATOR)
if spent + tokens > history_budget:
if not included_actions:
# Even the newest action alone is over budget: hard-truncate it.
included_actions.append(
models.Action(
adventure_id=action.adventure_id, index=action.index,
type=action.type,
text=truncate_to_last_tokens(action.text, history_budget),
)
)
oldest_truncated = True
break
included_actions.append(action)
spent += tokens
included_actions.reverse()
# ----- Assemble story text with author's note near the end -----
texts = [a.text for a in included_actions]
note_sections: list[Section] = []
if authors_note:
pos = max(0, len(texts) - AUTHORS_NOTE_DEPTH)
before, after = texts[:pos], texts[pos:]
if before:
note_sections.append(Section("history", SEPARATOR.join(before)))
note_sections.append(Section("authors_note", authors_note))
note_sections.append(Section("recent_history", SEPARATOR.join(after)))
else:
note_sections.append(Section("history", SEPARATOR.join(texts)))
if front_memory:
note_sections.append(Section("front_memory", front_memory))
story_sections = [s for s in note_sections if s.text]
system_text = SEPARATOR.join(s.text for s in system_sections if s.text)
story_text = SEPARATOR.join(s.text for s in story_sections)
all_sections = [s for s in system_sections if s.text] + story_sections
report = {
"sections": [
{"label": s.label, "text": s.text, "tokens": s.tokens} for s in all_sections
],
"prompt": {"system": system_text, "story": story_text},
"tokens": {
"total": count_tokens(system_text) + count_tokens(story_text),
"budget": settings.context_token_budget,
},
"cards": card_records,
"memories": memory_bank,
"history": {
"included": len(included_actions),
"total": len(actions),
"oldest_truncated": oldest_truncated,
},
"settings": {
"model": settings.model,
"api_mode": settings.api_mode,
"temperature": settings.temperature,
"max_output_tokens": settings.max_output_tokens,
},
}
return system_text, story_text, report
+33
View File
@@ -0,0 +1,33 @@
from pathlib import Path
from sqlalchemy import create_engine, event
from sqlalchemy.orm import DeclarativeBase, sessionmaker
DB_PATH = Path(__file__).resolve().parent.parent / "data.db"
engine = create_engine(
f"sqlite:///{DB_PATH}",
connect_args={"check_same_thread": False},
)
@event.listens_for(engine, "connect")
def _enable_sqlite_foreign_keys(dbapi_connection, _record):
# SQLite ships with foreign keys OFF per connection; without this every
# ondelete=CASCADE/SET NULL in models.py is silently ignored.
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+56
View File
@@ -0,0 +1,56 @@
"""In-memory ring buffer of recent provider requests/responses for the debug page.
API keys never enter the log: only the request body (which carries no
credentials) and response text are recorded, both truncated.
"""
import itertools
from collections import deque
from datetime import datetime, timezone
MAX_ENTRIES = 30
MAX_TEXT = 6000
_entries: deque[dict] = deque(maxlen=MAX_ENTRIES)
_ids = itertools.count(1)
def _clip(text: str) -> str:
if len(text) <= MAX_TEXT:
return text
return text[:MAX_TEXT] + f"\n… [{len(text) - MAX_TEXT} more chars truncated]"
def _clip_obj(obj):
if isinstance(obj, str):
return _clip(obj)
if isinstance(obj, dict):
return {k: _clip_obj(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_clip_obj(v) for v in obj]
return obj
def start_entry(url: str, model: str, body: dict) -> dict:
entry = {
"id": next(_ids),
"time": datetime.now(timezone.utc).isoformat(),
"url": url,
"model": model,
"request": _clip_obj(body),
"status": "pending",
"response": "",
"error": None,
}
_entries.appendleft(entry)
return entry
def finish_entry(entry: dict, *, response: str = "", error: str | None = None) -> None:
entry["response"] = _clip(response)
entry["error"] = error
entry["status"] = "error" if error else "ok"
def recent() -> list[dict]:
return list(_entries)
+55
View File
@@ -0,0 +1,55 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from .database import engine
from .migrations import bootstrap
from .routers import adventures, debug, scenarios, scripts, settings, story_cards
bootstrap(engine)
app = FastAPI(title="AI D&D")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(scenarios.router)
app.include_router(adventures.router)
app.include_router(story_cards.router)
app.include_router(scripts.router)
app.include_router(settings.router)
app.include_router(debug.router)
@app.get("/api/health")
def health():
return {"ok": True}
# In production, serve the built frontend (frontend/dist) as static files.
class SPAStaticFiles(StaticFiles):
"""Serve index.html for unknown paths so client-side routes (/play/3)
survive a page reload. API routes are matched before this mount."""
async def get_response(self, path, scope):
try:
response = await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code != 404:
raise
return await super().get_response("index.html", scope)
if response.status_code == 404:
return await super().get_response("index.html", scope)
return response
frontend_dist = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
if frontend_dist.is_dir():
app.mount("/", SPAStaticFiles(directory=frontend_dist, html=True), name="frontend")
+275
View File
@@ -0,0 +1,275 @@
"""Phase 6 — auto summarization + embedding memory bank
(per help.aidungeon.com/faq/the-memory-system).
After each turn, a fire-and-forget task (`run_post_turn`) runs with its own DB
session:
- every MEMORY_INTERVAL actions (starting at MEMORY_START), each uncovered
block of actions is summarized into a short "memory";
- every SUMMARY_INTERVAL actions, the Story Summary is rewritten folding in
the new memories (the user-edited text is always the base, never clobbered);
- new memories are embedded (OpenAI-compatible /v1/embeddings) and the bank
is evicted down to capacity ("forgotten" memories are kept for the UI).
At generation time, `retrieve_memories` embeds the recent story text and ranks
the bank by cosine similarity; the top-K become the "Memories" context section.
All AI calls here are best-effort: failures are logged (debug page) and retried
on a later turn because the cursors only advance on success.
"""
import asyncio
import math
from sqlalchemy.orm import Session
from . import models
from .context import truncate_to_last_tokens
from .database import SessionLocal
from .providers import OpenAICompatibleProvider, ProviderError
MEMORY_INTERVAL = 6 # actions per memory
MEMORY_START = 12 # first memory once the adventure reaches this many actions
SUMMARY_INTERVAL = 15 # actions between Story Summary updates
MAX_MEMORIES_PER_RUN = 5 # cap catch-up work (e.g. imported adventures) per turn
MAX_EMBED_BATCH = 32
RETRIEVAL_WINDOW_TOKENS = 600 # recent story text used as the similarity query
SUMMARY_MAX_WORDS = 250
MEMORY_SYSTEM_PROMPT = (
"You compress interactive-fiction story excerpts into memories. Respond with "
"1-2 plain sentences in past tense stating the concrete facts and events "
"(names, places, items, promises, injuries). No preamble, no commentary."
)
SUMMARY_SYSTEM_PROMPT = (
"You maintain the running summary of an interactive-fiction story. Respond "
"with only the updated summary: a single plain-prose overview of the plot "
f"so far, at most {SUMMARY_MAX_WORDS} words. Preserve important established "
"facts; compress older events harder than recent ones."
)
# Adventures with a post-turn task currently running (single-process app).
_running: set[int] = set()
# Strong refs to in-flight tasks — the event loop only keeps weak references,
# so a fire-and-forget task can otherwise be garbage-collected mid-run.
_tasks: set[asyncio.Task] = set()
def summary_provider(settings: models.Settings) -> OpenAICompatibleProvider:
return OpenAICompatibleProvider(
settings.endpoint_url,
settings.api_key,
settings.summary_model or settings.model,
settings.api_mode,
settings.reasoning_max_tokens,
)
def embedding_provider(settings: models.Settings) -> OpenAICompatibleProvider:
return OpenAICompatibleProvider(
settings.endpoint_url, settings.api_key, settings.embedding_model
)
def cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
return dot / norm if norm else 0.0
def story_actions(adventure: models.Adventure) -> list[models.Action]:
return [a for a in adventure.actions if a.text.strip()]
# ---------- Retrieval (runs inside the turn, before build_context) ----------
async def retrieve_memories(
adventure: models.Adventure,
settings: models.Settings,
*,
update_stats: bool,
) -> dict | None:
"""Returns {"used": [{id, text, similarity, pinned}], "error": str|None},
or None when the memory bank is off for this adventure. `update_stats`
bumps use counters (real turns only, not Insights dry runs); the caller's
commit persists them."""
if not adventure.memory_bank_enabled:
return None
if not settings.embedding_model.strip():
return {"used": [], "error": "No embedding model configured in Settings."}
candidates = [m for m in adventure.memories if not m.forgotten and m.embedding]
if not candidates:
return {"used": [], "error": None}
actions = story_actions(adventure)
query = truncate_to_last_tokens(
"\n\n".join(a.text for a in actions[-4:]), RETRIEVAL_WINDOW_TOKENS
)
if not query.strip():
return {"used": [], "error": None}
try:
[query_vec] = await embedding_provider(settings).embed([query])
except ProviderError as exc:
return {"used": [], "error": str(exc)}
scored = sorted(
((cosine(query_vec, m.embedding), m) for m in candidates),
key=lambda pair: pair[0],
reverse=True,
)
# Pinned memories are always used; the rest fill up to top_k by similarity.
top_k = max(1, settings.memory_top_k)
used = [(score, m) for score, m in scored if m.pinned]
used += [(score, m) for score, m in scored if not m.pinned][:top_k]
used.sort(key=lambda pair: pair[0], reverse=True)
if update_stats:
now = models.utcnow()
for _, m in used:
m.use_count += 1
m.last_used_at = now
return {
"used": [
{"id": m.id, "text": m.text, "similarity": round(score, 4), "pinned": m.pinned}
for score, m in used
],
"error": None,
}
# ---------- Post-turn background work ----------
def schedule_post_turn(adventure: models.Adventure) -> None:
"""Fire-and-forget summarization/embedding work after a turn is saved."""
if not (adventure.auto_summarize or adventure.memory_bank_enabled):
return
if adventure.id in _running:
return
task = asyncio.get_running_loop().create_task(run_post_turn(adventure.id))
_tasks.add(task)
task.add_done_callback(_tasks.discard)
async def run_post_turn(adventure_id: int) -> None:
if adventure_id in _running:
return
_running.add(adventure_id)
db = SessionLocal()
try:
adventure = db.get(models.Adventure, adventure_id)
settings = db.get(models.Settings, 1)
if adventure is None or settings is None:
return
if adventure.auto_summarize:
await _create_due_memories(adventure, settings, db)
await _update_story_summary(adventure, settings, db)
if adventure.memory_bank_enabled and settings.embedding_model.strip():
await _embed_pending(adventure, settings, db)
_evict_over_capacity(adventure, settings, db)
finally:
db.close()
_running.discard(adventure_id)
async def _create_due_memories(
adventure: models.Adventure, settings: models.Settings, db: Session
) -> None:
actions = story_actions(adventure)
provider = summary_provider(settings)
for _ in range(MAX_MEMORIES_PER_RUN):
cursor = adventure.memory_cursor
if len(actions) < MEMORY_START or len(actions) - cursor < MEMORY_INTERVAL:
return
block = actions[cursor:cursor + MEMORY_INTERVAL]
excerpt = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000)
try:
text = await provider.complete(
MEMORY_SYSTEM_PROMPT, f"Story excerpt:\n\n{excerpt}\n\nMemory:"
)
except ProviderError:
return # logged in the debug page; cursor unchanged → retried next turn
if not text:
return
db.add(
models.Memory(
adventure_id=adventure.id,
text=text,
source_start=block[0].index,
source_end=block[-1].index,
)
)
adventure.memory_cursor = cursor + MEMORY_INTERVAL
db.commit()
async def _update_story_summary(
adventure: models.Adventure, settings: models.Settings, db: Session
) -> None:
actions = story_actions(adventure)
if len(actions) - adventure.summary_cursor < SUMMARY_INTERVAL:
return
# Fold in memories covering the uncovered stretch; fall back to raw story
# text if memory creation is lagging (e.g. it just failed).
new_events = [
m.text
for m in adventure.memories
if m.source_end is not None and m.source_end >= adventure.summary_cursor
]
if new_events:
events_text = "\n".join(f"- {t}" for t in new_events)
else:
block = actions[adventure.summary_cursor:]
events_text = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000)
current = adventure.story_summary.strip()
user_prompt = (
f"Current story summary:\n{current or '(none yet)'}\n\n"
f"New events since the last update:\n{events_text}\n\n"
"Updated summary:"
)
try:
text = await summary_provider(settings).complete(
SUMMARY_SYSTEM_PROMPT, user_prompt, max_tokens=600
)
except ProviderError:
return
if not text:
return
adventure.story_summary = text
adventure.summary_cursor = len(actions)
db.commit()
async def _embed_pending(
adventure: models.Adventure, settings: models.Settings, db: Session
) -> None:
pending = [m for m in adventure.memories if m.embedding is None and not m.forgotten]
pending = pending[:MAX_EMBED_BATCH]
if not pending:
return
try:
vectors = await embedding_provider(settings).embed([m.text for m in pending])
except ProviderError:
return
for memory, vector in zip(pending, vectors):
memory.embedding = vector
db.commit()
def _evict_over_capacity(
adventure: models.Adventure, settings: models.Settings, db: Session
) -> None:
active = [m for m in adventure.memories if not m.forgotten]
overflow = len(active) - max(1, settings.memory_bank_capacity)
if overflow <= 0:
return
evictable = sorted(
(m for m in active if not m.pinned),
key=lambda m: (m.use_count, m.last_used_at or m.created_at),
)
for memory in evictable[:overflow]:
memory.forgotten = True
db.commit()
+66
View File
@@ -0,0 +1,66 @@
"""Lightweight versioned schema migrations over SQLite's PRAGMA user_version.
How it works:
- A fresh database is created by `Base.metadata.create_all()` (always current)
and stamped with LATEST_VERSION.
- An existing database runs every migration whose version is greater than its
stored user_version, in order, then is stamped.
To change the schema: update models.py (keeps fresh DBs current) AND append a
(version, sql) pair here (upgrades existing DBs). Keep migrations idempotent
where cheap (IF NOT EXISTS etc.).
"""
from sqlalchemy import inspect, text
from sqlalchemy.engine import Engine
from .database import Base
# (version, SQL to run when upgrading past it) — append only, never reorder.
MIGRATIONS: list[tuple[int, str]] = [
# Phase 6: auto-summarization + memory bank (the `memories` table itself is
# created by create_all, which runs for existing DBs too).
(2, "ALTER TABLE adventures ADD COLUMN auto_summarize BOOLEAN NOT NULL DEFAULT 0"),
(3, "ALTER TABLE adventures ADD COLUMN memory_bank_enabled BOOLEAN NOT NULL DEFAULT 0"),
(4, "ALTER TABLE adventures ADD COLUMN memory_cursor INTEGER NOT NULL DEFAULT 0"),
(5, "ALTER TABLE adventures ADD COLUMN summary_cursor INTEGER NOT NULL DEFAULT 0"),
(6, "ALTER TABLE settings ADD COLUMN summary_model VARCHAR(200) NOT NULL DEFAULT ''"),
(7, "ALTER TABLE settings ADD COLUMN embedding_model VARCHAR(200) NOT NULL DEFAULT ''"),
(8, "ALTER TABLE settings ADD COLUMN memory_bank_capacity INTEGER NOT NULL DEFAULT 200"),
(9, "ALTER TABLE settings ADD COLUMN memory_top_k INTEGER NOT NULL DEFAULT 5"),
# Repair duplicate action indexes (player + AI actions of one turn used to
# get the same index): renumber 0..n-1 per adventure, preserving order.
# UPDATE..FROM: ranks are computed as a snapshot before any row is
# rewritten (a correlated subquery would see partially-updated rows and
# could produce duplicates again).
(10, """
UPDATE actions SET "index" = ranked.new_index
FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY adventure_id ORDER BY "index", id
) - 1 AS new_index
FROM actions
) AS ranked
WHERE ranked.id = actions.id
"""),
# Reasoning-model support: separate thinking budget + stored reasoning text.
(11, "ALTER TABLE settings ADD COLUMN reasoning_max_tokens INTEGER NOT NULL DEFAULT 0"),
(12, "ALTER TABLE actions ADD COLUMN reasoning TEXT"),
]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
def bootstrap(engine: Engine) -> None:
fresh = not inspect(engine).get_table_names()
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
if fresh:
conn.execute(text(f"PRAGMA user_version = {LATEST_VERSION}"))
return
current = conn.execute(text("PRAGMA user_version")).scalar() or 1
for version, sql in MIGRATIONS:
if version > current:
conn.execute(text(sql))
current = version
conn.execute(text(f"PRAGMA user_version = {current}"))
+221
View File
@@ -0,0 +1,221 @@
from datetime import datetime, timezone
from sqlalchemy import (
JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Table, Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
scenario_scripts = Table(
"scenario_scripts",
Base.metadata,
Column("scenario_id", ForeignKey("scenarios.id", ondelete="CASCADE"), primary_key=True),
Column("script_id", ForeignKey("scripts.id", ondelete="CASCADE"), primary_key=True),
)
class Scenario(Base):
__tablename__ = "scenarios"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200), default="Untitled Scenario")
description: Mapped[str] = mapped_column(Text, default="")
prompt: Mapped[str] = mapped_column(Text, default="")
# Plot components (AI Dungeon terminology; `memory` == Plot Essentials)
memory: Mapped[str] = mapped_column(Text, default="")
authors_note: Mapped[str] = mapped_column(Text, default="")
ai_instructions: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[str] = mapped_column(String(500), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
story_cards: Mapped[list["StoryCard"]] = relationship(
back_populates="scenario", cascade="all, delete-orphan"
)
adventures: Mapped[list["Adventure"]] = relationship(back_populates="scenario")
scripts: Mapped[list["Script"]] = relationship(secondary=scenario_scripts)
class Adventure(Base):
__tablename__ = "adventures"
id: Mapped[int] = mapped_column(primary_key=True)
scenario_id: Mapped[int | None] = mapped_column(
ForeignKey("scenarios.id", ondelete="SET NULL"), nullable=True
)
title: Mapped[str] = mapped_column(String(200), default="Untitled Adventure")
memory: Mapped[str] = mapped_column(Text, default="")
authors_note: Mapped[str] = mapped_column(Text, default="")
ai_instructions: Mapped[str] = mapped_column(Text, default="")
story_summary: Mapped[str] = mapped_column(Text, default="")
script_state: Mapped[dict] = mapped_column(JSON, default=dict)
# Phase 6: opt-in per adventure (extra AI calls)
auto_summarize: Mapped[bool] = mapped_column(Boolean, default=False)
memory_bank_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
# How many actions have already been folded into memories / the story summary.
memory_cursor: Mapped[int] = mapped_column(Integer, default=0)
summary_cursor: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
scenario: Mapped[Scenario | None] = relationship(back_populates="adventures")
story_cards: Mapped[list["StoryCard"]] = relationship(
back_populates="adventure", cascade="all, delete-orphan"
)
actions: Mapped[list["Action"]] = relationship(
back_populates="adventure",
cascade="all, delete-orphan",
order_by="Action.index",
)
scripts: Mapped[list["AdventureScript"]] = relationship(
back_populates="adventure",
cascade="all, delete-orphan",
order_by="AdventureScript.position",
)
memories: Mapped[list["Memory"]] = relationship(
back_populates="adventure",
cascade="all, delete-orphan",
order_by="Memory.id",
)
class Memory(Base):
"""Phase 6: an auto-summarized (or hand-written) fact about the adventure.
`embedding` is the raw vector as a JSON list (cosine ranking happens in
Python — fine at bank sizes of a few hundred). NULL until embedded, which
also marks it for backfill when an embedding model becomes available.
"""
__tablename__ = "memories"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
text: Mapped[str] = mapped_column(Text, default="")
embedding: Mapped[list | None] = mapped_column(JSON, nullable=True)
# Action index range this memory summarizes (null for manual memories).
source_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
forgotten: Mapped[bool] = mapped_column(Boolean, default=False) # evicted, kept for UI
use_count: Mapped[int] = mapped_column(Integer, default=0)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
adventure: Mapped[Adventure] = relationship(back_populates="memories")
@property
def embedded(self) -> bool:
return self.embedding is not None
class StoryCard(Base):
"""Owned by either a scenario or an adventure (exactly one set)."""
__tablename__ = "story_cards"
id: Mapped[int] = mapped_column(primary_key=True)
scenario_id: Mapped[int | None] = mapped_column(
ForeignKey("scenarios.id", ondelete="CASCADE"), nullable=True
)
adventure_id: Mapped[int | None] = mapped_column(
ForeignKey("adventures.id", ondelete="CASCADE"), nullable=True
)
type: Mapped[str] = mapped_column(String(100), default="")
name: Mapped[str] = mapped_column(String(200), default="")
keys: Mapped[str] = mapped_column(Text, default="") # comma-separated triggers
entry: Mapped[str] = mapped_column(Text, default="")
notes: Mapped[str] = mapped_column(Text, default="")
scenario: Mapped[Scenario | None] = relationship(back_populates="story_cards")
adventure: Mapped[Adventure | None] = relationship(back_populates="story_cards")
class Action(Base):
__tablename__ = "actions"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
index: Mapped[int] = mapped_column(Integer)
type: Mapped[str] = mapped_column(String(20)) # start|do|say|story|continue|ai
text: Mapped[str] = mapped_column(Text, default="")
# Reasoning-model "thinking" that preceded the text (AI actions only).
reasoning: Mapped[str | None] = mapped_column(Text, nullable=True)
context_snapshot: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
adventure: Mapped[Adventure] = relationship(back_populates="actions")
class Script(Base):
__tablename__ = "scripts"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200), default="Untitled Script")
description: Mapped[str] = mapped_column(Text, default="")
library_js: Mapped[str] = mapped_column(Text, default="")
input_js: Mapped[str] = mapped_column(Text, default="")
context_js: Mapped[str] = mapped_column(Text, default="")
output_js: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
class AdventureScript(Base):
"""A script copied into an adventure at creation, so library edits don't
change running adventures. `state` lives on Adventure.script_state (one
shared state per adventure, as in AI Dungeon)."""
__tablename__ = "adventure_scripts"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
position: Mapped[int] = mapped_column(Integer, default=0)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
name: Mapped[str] = mapped_column(String(200), default="Untitled Script")
description: Mapped[str] = mapped_column(Text, default="")
library_js: Mapped[str] = mapped_column(Text, default="")
input_js: Mapped[str] = mapped_column(Text, default="")
context_js: Mapped[str] = mapped_column(Text, default="")
output_js: Mapped[str] = mapped_column(Text, default="")
adventure: Mapped[Adventure] = relationship(back_populates="scripts")
class Settings(Base):
__tablename__ = "settings"
id: Mapped[int] = mapped_column(primary_key=True) # single row, id=1
endpoint_url: Mapped[str] = mapped_column(String(500), default="http://localhost:11434/v1")
api_key: Mapped[str] = mapped_column(String(500), default="")
model: Mapped[str] = mapped_column(String(200), default="")
api_mode: Mapped[str] = mapped_column(String(20), default="chat") # chat|completion
temperature: Mapped[float] = mapped_column(Float, default=0.8)
max_output_tokens: Mapped[int] = mapped_column(Integer, default=400)
# Separate thinking budget for reasoning models (OpenRouter-style
# `reasoning: {max_tokens}`); 0 = param not sent. Added on top of
# max_output_tokens so story output keeps its full budget.
reasoning_max_tokens: Mapped[int] = mapped_column(Integer, default=0)
context_token_budget: Mapped[int] = mapped_column(Integer, default=4096)
narrator_prompt: Mapped[str] = mapped_column(
Text,
default=(
"You are a masterful storyteller continuing an interactive adventure. "
"Continue the story naturally in second person, staying consistent with "
"everything established so far. Write vivid prose. Never speak for the "
"player or break character. Do not conclude the story; always leave room "
"for the player's next action."
),
)
stream: Mapped[bool] = mapped_column(Boolean, default=True)
# Phase 6: auto-summarization + memory bank
summary_model: Mapped[str] = mapped_column(String(200), default="") # "" = main model
embedding_model: Mapped[str] = mapped_column(String(200), default="") # "" = bank disabled
memory_bank_capacity: Mapped[int] = mapped_column(Integer, default=200)
memory_top_k: Mapped[int] = mapped_column(Integer, default=5)
+4
View File
@@ -0,0 +1,4 @@
from .base import PromptParts, Provider, ProviderError
from .openai_compatible import OpenAICompatibleProvider
__all__ = ["PromptParts", "Provider", "ProviderError", "OpenAICompatibleProvider"]
+27
View File
@@ -0,0 +1,27 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import AsyncIterator
@dataclass
class PromptParts:
"""Assembled context, provider-agnostic. Providers map this to their wire format."""
system: str # narrator prompt + AI instructions + memory
story: str # the story text so far (already token-budgeted)
class ProviderError(Exception):
"""User-presentable provider failure (connection refused, bad key, model not found…)."""
class Provider(ABC):
@abstractmethod
def generate(
self,
parts: PromptParts,
*,
temperature: float,
max_tokens: int,
) -> AsyncIterator[tuple[str, str]]:
"""Yield ("text" | "reasoning", chunk) pairs. Raises ProviderError on failure."""
+242
View File
@@ -0,0 +1,242 @@
import json
from typing import AsyncIterator
import httpx
from .. import debuglog
from .base import PromptParts, Provider, ProviderError
# Framing appended after the story text in chat mode, so chat-tuned models keep
# continuing prose instead of replying conversationally.
CHAT_CONTINUE_HINT = "\n\n[Continue the story directly. Output only story text.]"
class OpenAICompatibleProvider(Provider):
"""Adapter for any /v1-style endpoint: Ollama, LM Studio, OpenAI, OpenRouter, vLLM, Groq…"""
def __init__(
self,
endpoint_url: str,
api_key: str,
model: str,
api_mode: str = "chat",
reasoning_max_tokens: int = 0,
):
self.base_url = endpoint_url.rstrip("/")
self.api_key = api_key
self.model = model
self.api_mode = api_mode # "chat" | "completion"
# Thinking budget for reasoning models, on top of max_tokens. 0 = the
# `reasoning` param is not sent (endpoints that don't know it may
# reject unknown fields).
self.reasoning_max_tokens = reasoning_max_tokens
def _headers(self) -> dict:
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
def _apply_reasoning_budget(self, body: dict) -> None:
"""Give reasoning models their own thinking budget (OpenRouter-style),
raising max_tokens so the actual output keeps its full budget."""
if self.reasoning_max_tokens > 0 and self.api_mode == "chat":
body["reasoning"] = {"max_tokens": self.reasoning_max_tokens}
body["max_tokens"] += self.reasoning_max_tokens
def _request(self, parts: PromptParts, temperature: float, max_tokens: int) -> tuple[str, dict]:
if self.api_mode == "completion":
url = f"{self.base_url}/completions"
body = {
"model": self.model,
"prompt": f"{parts.system}\n\n{parts.story}",
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
}
else:
url = f"{self.base_url}/chat/completions"
body = {
"model": self.model,
"messages": [
{"role": "system", "content": parts.system},
{"role": "user", "content": parts.story + CHAT_CONTINUE_HINT},
],
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
}
self._apply_reasoning_budget(body)
return url, body
@staticmethod
def _extract_chunk(payload: dict) -> str:
choices = payload.get("choices") or []
if not choices:
return ""
choice = choices[0]
# chat stream → delta.content; completion stream → text;
# non-stream fallbacks → message.content / text
delta = choice.get("delta") or {}
return (
delta.get("content")
or choice.get("text")
or (choice.get("message") or {}).get("content")
or ""
)
@staticmethod
def _extract_reasoning(payload: dict) -> str:
"""Reasoning-model thinking: OpenRouter normalizes to `reasoning`;
DeepSeek-style servers use `reasoning_content`."""
choices = payload.get("choices") or []
if not choices:
return ""
choice = choices[0]
delta = choice.get("delta") or {}
message = choice.get("message") or {}
return (
delta.get("reasoning")
or delta.get("reasoning_content")
or message.get("reasoning")
or message.get("reasoning_content")
or ""
)
async def generate(
self, parts: PromptParts, *, temperature: float, max_tokens: int
) -> AsyncIterator[tuple[str, str]]:
"""Yields ("text" | "reasoning", chunk) pairs."""
if not self.model:
raise ProviderError("No model configured — set one in Settings.")
url, body = self._request(parts, temperature, max_tokens)
log = debuglog.start_entry(url, self.model, body)
received: list[str] = []
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=10)) as client:
async with client.stream("POST", url, json=body, headers=self._headers()) as resp:
if resp.status_code != 200:
detail = (await resp.aread()).decode(errors="replace")[:500]
raise ProviderError(self._friendly_http_error(resp.status_code, detail))
async for line in resp.aiter_lines():
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
debuglog.finish_entry(log, response="".join(received))
return
try:
payload = json.loads(data)
except ValueError:
continue
reasoning = self._extract_reasoning(payload)
if reasoning:
yield "reasoning", reasoning
chunk = self._extract_chunk(payload)
if chunk:
received.append(chunk)
yield "text", chunk
debuglog.finish_entry(log, response="".join(received))
except httpx.ConnectError as exc:
error = f"Could not connect to {self.base_url} — is the AI server running?"
debuglog.finish_entry(log, response="".join(received), error=error)
raise ProviderError(error) from exc
except httpx.TimeoutException as exc:
debuglog.finish_entry(log, response="".join(received), error="Timed out")
raise ProviderError("The AI endpoint timed out.") from exc
except httpx.HTTPError as exc:
debuglog.finish_entry(log, response="".join(received), error=str(exc))
raise ProviderError(f"Request to AI endpoint failed: {exc}") from exc
except (ProviderError, GeneratorExit, BaseException) as exc:
status = "cancelled" if isinstance(exc, GeneratorExit) else str(exc)
debuglog.finish_entry(log, response="".join(received), error=status)
raise
async def complete(
self, system: str, user: str, *, temperature: float = 0.3, max_tokens: int = 400
) -> str:
"""Single non-streaming completion for background calls (summarization).
Unlike generate(), no story-continuation framing is added."""
if not self.model:
raise ProviderError("No model configured — set one in Settings.")
if self.api_mode == "completion":
url = f"{self.base_url}/completions"
body = {
"model": self.model,
"prompt": f"{system}\n\n{user}",
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
}
else:
url = f"{self.base_url}/chat/completions"
body = {
"model": self.model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
}
self._apply_reasoning_budget(body)
log = debuglog.start_entry(url, self.model, body)
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=10)) as client:
resp = await client.post(url, json=body, headers=self._headers())
except httpx.HTTPError as exc:
debuglog.finish_entry(log, error=str(exc))
raise ProviderError(f"Request to AI endpoint failed: {exc}") from exc
if resp.status_code != 200:
error = self._friendly_http_error(resp.status_code, resp.text[:500])
debuglog.finish_entry(log, error=error)
raise ProviderError(error)
try:
text = self._extract_chunk(resp.json())
except ValueError as exc:
debuglog.finish_entry(log, error="Invalid JSON response")
raise ProviderError("AI endpoint returned invalid JSON.") from exc
debuglog.finish_entry(log, response=text)
return text.strip()
async def embed(self, texts: list[str]) -> list[list[float]]:
"""POST /v1/embeddings; self.model is the embedding model here."""
if not self.model:
raise ProviderError("No embedding model configured — set one in Settings.")
url = f"{self.base_url}/embeddings"
body = {"model": self.model, "input": texts}
log = debuglog.start_entry(url, self.model, body)
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(60, connect=10)) as client:
resp = await client.post(url, json=body, headers=self._headers())
except httpx.HTTPError as exc:
debuglog.finish_entry(log, error=str(exc))
raise ProviderError(f"Embedding request failed: {exc}") from exc
if resp.status_code != 200:
error = self._friendly_http_error(resp.status_code, resp.text[:500])
debuglog.finish_entry(log, error=error)
raise ProviderError(error)
try:
data = resp.json().get("data", [])
vectors = [item["embedding"] for item in sorted(data, key=lambda d: d.get("index", 0))]
except (ValueError, KeyError, TypeError) as exc:
debuglog.finish_entry(log, error="Malformed embeddings response")
raise ProviderError("AI endpoint returned malformed embeddings.") from exc
if len(vectors) != len(texts):
debuglog.finish_entry(log, error="Embedding count mismatch")
raise ProviderError("AI endpoint returned the wrong number of embeddings.")
debuglog.finish_entry(log, response=f"{len(vectors)} vectors × {len(vectors[0]) if vectors else 0} dims")
return vectors
def _friendly_http_error(self, status: int, detail: str) -> str:
if status == 401:
return "Authentication failed — check your API key in Settings."
if status == 404:
return (
f"Endpoint or model not found (HTTP 404). Check the endpoint URL and that "
f"model '{self.model}' exists. {detail}"
)
return f"AI endpoint returned HTTP {status}: {detail}"
View File
+611
View File
@@ -0,0 +1,611 @@
import json
import re
import threading
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from .. import memorybank, models, schemas
from ..context import build_context
from ..database import get_db
from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError
from ..scripting import ScriptPipeline
from .settings import get_settings
router = APIRouter(prefix="/api/adventures", tags=["adventures"])
def get_adventure_or_404(adventure_id: int, db: Session) -> models.Adventure:
adventure = db.get(models.Adventure, adventure_id)
if adventure is None:
raise HTTPException(404, "Adventure not found")
return adventure
@router.get("", response_model=list[schemas.AdventureListItem])
def list_adventures(db: Session = Depends(get_db)):
rows = (
db.query(models.Adventure, func.count(models.Action.id), models.Scenario.title)
.outerjoin(models.Action)
.outerjoin(models.Scenario, models.Adventure.scenario_id == models.Scenario.id)
.group_by(models.Adventure.id)
.order_by(models.Adventure.updated_at.desc())
.all()
)
return [
schemas.AdventureListItem(
id=adv.id,
scenario_id=adv.scenario_id,
scenario_title=scenario_title,
title=adv.title,
updated_at=adv.updated_at,
action_count=count,
)
for adv, count, scenario_title in rows
]
PLACEHOLDER_RE = re.compile(r"\$\{([^}]+)\}")
def fill_placeholders(text: str, values: dict[str, str]) -> str:
"""Replace ${Name} with the player-provided value; unknown names are left as-is."""
if not text or not values:
return text
return PLACEHOLDER_RE.sub(
lambda m: values.get(m.group(1).strip(), m.group(0)), text
)
@router.post("", response_model=schemas.AdventureOut, status_code=201)
def create_adventure(payload: schemas.AdventureCreate, db: Session = Depends(get_db)):
scenario = None
if payload.scenario_id is not None:
scenario = db.get(models.Scenario, payload.scenario_id)
if scenario is None:
raise HTTPException(404, "Scenario not found")
values = payload.placeholders
adventure = models.Adventure(
scenario_id=scenario.id if scenario else None,
title=payload.title or (scenario.title if scenario else "Untitled Adventure"),
memory=fill_placeholders(scenario.memory, values) if scenario else "",
authors_note=fill_placeholders(scenario.authors_note, values) if scenario else "",
ai_instructions=fill_placeholders(scenario.ai_instructions, values) if scenario else "",
)
db.add(adventure)
db.flush()
if scenario:
for card in scenario.story_cards:
db.add(
models.StoryCard(
adventure_id=adventure.id,
type=card.type,
name=card.name,
keys=fill_placeholders(card.keys, values),
entry=fill_placeholders(card.entry, values),
notes=card.notes,
)
)
for position, script in enumerate(scenario.scripts):
db.add(
models.AdventureScript(
adventure_id=adventure.id,
position=position,
name=script.name,
description=script.description,
library_js=script.library_js,
input_js=script.input_js,
context_js=script.context_js,
output_js=script.output_js,
)
)
if scenario.prompt.strip():
db.add(
models.Action(
adventure_id=adventure.id,
index=0,
type="start",
text=fill_placeholders(scenario.prompt, values),
)
)
db.commit()
db.refresh(adventure)
return adventure
@router.get("/{adventure_id}", response_model=schemas.AdventureOut)
def get_adventure(adventure_id: int, db: Session = Depends(get_db)):
return get_adventure_or_404(adventure_id, db)
@router.patch("/{adventure_id}", response_model=schemas.AdventureOut)
def update_adventure(
adventure_id: int, payload: schemas.AdventureUpdate, db: Session = Depends(get_db)
):
adventure = get_adventure_or_404(adventure_id, db)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(adventure, field, value)
db.commit()
return adventure
@router.delete("/{adventure_id}", status_code=204)
def delete_adventure(adventure_id: int, db: Session = Depends(get_db)):
adventure = get_adventure_or_404(adventure_id, db)
db.delete(adventure)
db.commit()
# ---------- Turn engine ----------
# One turn at a time per adventure (in-memory; fine for a single-process local app).
# Sync endpoints run in a threadpool, so the check-and-add must be guarded — and
# it must happen in the request phase, not when the SSE generator first runs,
# or two rapid requests both pass the check and generate concurrently.
_active_turns: set[int] = set()
_active_turns_guard = threading.Lock()
def acquire_turn_lock(adventure_id: int):
"""Atomically claim the adventure's turn slot; with_turn_lock releases it."""
with _active_turns_guard:
if adventure_id in _active_turns:
raise HTTPException(409, "A turn is already generating for this adventure.")
_active_turns.add(adventure_id)
async def with_turn_lock(adventure_id: int, gen):
"""Wrap an SSE generator so the lock (from acquire_turn_lock) is released."""
try:
async for event in gen:
yield event
finally:
_active_turns.discard(adventure_id)
def format_player_input(action_type: str, text: str) -> str:
"""AI Dungeon input conventions."""
text = text.strip()
if action_type == "say":
text = text.strip('"')
if text and text[-1] not in ".!?…":
text += "."
return f'> You say "{text}"'
if action_type == "do":
if text.lower().startswith("you "):
text = text[4:]
if text and text[-1] not in ".!?…":
text += "."
return f"> You {text}"
return text # story: raw text appended
def sse(obj: dict) -> str:
return f"data: {json.dumps(obj)}\n\n"
def action_json(action: models.Action) -> dict:
return schemas.ActionOut.model_validate(action).model_dump(mode="json")
def next_index(adventure: models.Adventure) -> int:
return max((a.index for a in adventure.actions), default=-1) + 1
async def generate_turn(adventure: models.Adventure, db: Session, pipeline: ScriptPipeline):
"""SSE generator: streams the AI continuation through the context/output
script hooks, then stores the result."""
settings = get_settings(db)
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=True)
system_text, story_text, snapshot = build_context(adventure, settings, memories)
# onModelContext: scripts see (and may rewrite) the whole assembled context.
combined = f"{system_text}\n\n{story_text}" if system_text else story_text
modified, stop = pipeline.run("context", combined)
if stop:
yield sse({"type": "stopped", "script": pipeline.report()})
return
context_changed = modified != combined
parts = (
PromptParts(system="", story=modified)
if context_changed
else PromptParts(system=system_text, story=story_text)
)
snapshot["script"] = pipeline.report() | {
"context_changed": context_changed,
"context_before": combined if context_changed else None,
"context_after": modified if context_changed else None,
}
provider = OpenAICompatibleProvider(
settings.endpoint_url, settings.api_key, settings.model, settings.api_mode,
settings.reasoning_max_tokens,
)
chunks: list[str] = []
reasoning_chunks: list[str] = []
try:
async for kind, chunk in provider.generate(
parts, temperature=settings.temperature, max_tokens=settings.max_output_tokens
):
if kind == "reasoning":
reasoning_chunks.append(chunk)
yield sse({"type": "reasoning", "text": chunk})
else:
chunks.append(chunk)
yield sse({"type": "chunk", "text": chunk})
except ProviderError as exc:
yield sse({"type": "error", "detail": str(exc)})
return
text = "".join(chunks).strip()
if not text:
yield sse({"type": "error", "detail": "The AI returned an empty response."})
return
# onOutput
text, _ = pipeline.run("output", text)
if not text.strip():
yield sse({"type": "error", "detail": "A script's output modifier returned empty text."})
return
snapshot["script"] = snapshot["script"] | pipeline.report()
ai_action = models.Action(
adventure_id=adventure.id,
index=next_index(adventure),
type="ai",
text=text,
reasoning="".join(reasoning_chunks).strip() or None,
context_snapshot=snapshot,
)
db.add(ai_action)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(ai_action)
yield sse({"type": "done", "action": action_json(ai_action), "script": pipeline.report()})
# Phase 6: fire-and-forget summarization/embedding (opens its own DB session).
memorybank.schedule_post_turn(adventure)
async def run_player_turn(
adventure: models.Adventure, db: Session, payload: schemas.ActionCreate
):
pipeline = ScriptPipeline(adventure, db)
# An empty do/say/story is just a continue.
if payload.type != "continue" and payload.text.strip():
# onInput sees the formatted text (as in AI Dungeon: "> You ...").
formatted = format_player_input(payload.type, payload.text)
modified, stop = pipeline.run("input", formatted)
if not modified.strip():
yield sse({"type": "error", "detail": "A script's input modifier returned empty text.",
"script": pipeline.report()})
return
player_action = models.Action(
adventure_id=adventure.id,
index=next_index(adventure),
type=payload.type,
text=modified,
)
db.add(player_action)
db.commit()
db.refresh(player_action)
# The new action was added via its FK, so the loaded adventure.actions
# collection is stale — without this, build_context and next_index for
# the AI action would not see the player action just saved.
db.expire(adventure, ["actions"])
yield sse({"type": "player", "action": action_json(player_action)})
if stop:
# onInput { stop: true } prevents the AI call.
yield sse({"type": "stopped", "script": pipeline.report()})
return
async for event in generate_turn(adventure, db, pipeline):
yield event
@router.post("/{adventure_id}/actions")
def create_action(
adventure_id: int, payload: schemas.ActionCreate, db: Session = Depends(get_db)
):
adventure = get_adventure_or_404(adventure_id, db)
acquire_turn_lock(adventure_id)
return StreamingResponse(
with_turn_lock(adventure_id, run_player_turn(adventure, db, payload)),
media_type="text/event-stream",
)
@router.post("/{adventure_id}/retry")
def retry_action(adventure_id: int, db: Session = Depends(get_db)):
"""Delete the last AI action and regenerate from the same input."""
adventure = get_adventure_or_404(adventure_id, db)
acquire_turn_lock(adventure_id)
try:
if adventure.actions and adventure.actions[-1].type == "ai":
db.delete(adventure.actions[-1])
db.commit()
db.refresh(adventure)
except BaseException:
_active_turns.discard(adventure_id)
raise
return StreamingResponse(
with_turn_lock(adventure_id, generate_turn(adventure, db, ScriptPipeline(adventure, db))),
media_type="text/event-stream",
)
@router.post("/{adventure_id}/undo", response_model=list[schemas.ActionOut])
def undo_turn(adventure_id: int, db: Session = Depends(get_db)):
"""Delete the last turn: the trailing AI action plus its player action, if any."""
adventure = get_adventure_or_404(adventure_id, db)
actions = list(adventure.actions)
if not actions or actions[-1].type == "start":
raise HTTPException(400, "Nothing to undo")
last = actions.pop()
db.delete(last)
if last.type == "ai" and actions and actions[-1].type in ("do", "say", "story"):
db.delete(actions.pop())
db.commit()
db.refresh(adventure)
return adventure.actions
# ---------- Import / Export ----------
@router.get("/{adventure_id}/export")
def export_adventure(adventure_id: int, db: Session = Depends(get_db)):
"""Full backup: plot components, story cards, scripts (+state), every action."""
adv = get_adventure_or_404(adventure_id, db)
return {
"format": "ai-dnd-adventure-v1",
"title": adv.title,
"memory": adv.memory,
"authorsNote": adv.authors_note,
"aiInstructions": adv.ai_instructions,
"storySummary": adv.story_summary,
"scriptState": adv.script_state,
"autoSummarize": adv.auto_summarize,
"memoryBankEnabled": adv.memory_bank_enabled,
"memoryCursor": adv.memory_cursor,
"summaryCursor": adv.summary_cursor,
"memories": [
{
"text": m.text, "pinned": m.pinned, "forgotten": m.forgotten,
"sourceStart": m.source_start, "sourceEnd": m.source_end,
"useCount": m.use_count,
}
for m in adv.memories
],
"storyCards": [
{"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes}
for c in adv.story_cards
],
"scripts": [
{
"position": s.position, "enabled": s.enabled,
"name": s.name, "description": s.description,
"library": s.library_js, "input": s.input_js,
"context": s.context_js, "output": s.output_js,
}
for s in adv.scripts
],
"actions": [
{
"index": a.index, "type": a.type, "text": a.text,
"reasoning": a.reasoning,
"createdAt": a.created_at.isoformat(),
}
for a in adv.actions
],
}
@router.post("/import", response_model=schemas.AdventureOut, status_code=201)
def import_adventure(bundle: dict = Body(...), db: Session = Depends(get_db)):
if bundle.get("format") != "ai-dnd-adventure-v1":
raise HTTPException(400, "Not an adventure export file (expected format ai-dnd-adventure-v1).")
adventure = models.Adventure(
title=str(bundle.get("title") or "Imported Adventure"),
memory=str(bundle.get("memory") or ""),
authors_note=str(bundle.get("authorsNote") or ""),
ai_instructions=str(bundle.get("aiInstructions") or ""),
story_summary=str(bundle.get("storySummary") or ""),
script_state=bundle.get("scriptState") or {},
auto_summarize=bool(bundle.get("autoSummarize", False)),
memory_bank_enabled=bool(bundle.get("memoryBankEnabled", False)),
memory_cursor=int(bundle.get("memoryCursor", 0)),
summary_cursor=int(bundle.get("summaryCursor", 0)),
)
db.add(adventure)
db.flush()
for m in bundle.get("memories") or []:
if isinstance(m, dict) and str(m.get("text") or "").strip():
db.add(models.Memory(
adventure_id=adventure.id,
text=str(m["text"]),
pinned=bool(m.get("pinned", False)),
forgotten=bool(m.get("forgotten", False)),
source_start=m.get("sourceStart"),
source_end=m.get("sourceEnd"),
use_count=int(m.get("useCount", 0)),
))
for card in bundle.get("storyCards") or []:
if isinstance(card, dict):
db.add(models.StoryCard(
adventure_id=adventure.id,
type=str(card.get("type") or ""),
name=str(card.get("name") or ""),
keys=str(card.get("keys") or ""),
entry=str(card.get("entry") or ""),
notes=str(card.get("notes") or ""),
))
for i, s in enumerate(bundle.get("scripts") or []):
if isinstance(s, dict):
db.add(models.AdventureScript(
adventure_id=adventure.id,
position=int(s.get("position", i)),
enabled=bool(s.get("enabled", True)),
name=str(s.get("name") or "Imported Script"),
description=str(s.get("description") or ""),
library_js=str(s.get("library") or ""),
input_js=str(s.get("input") or ""),
context_js=str(s.get("context") or ""),
output_js=str(s.get("output") or ""),
))
for i, a in enumerate(bundle.get("actions") or []):
if isinstance(a, dict) and str(a.get("text") or ""):
db.add(models.Action(
adventure_id=adventure.id,
index=int(a.get("index", i)),
type=str(a.get("type") or "story"),
text=str(a["text"]),
reasoning=str(a["reasoning"]) if a.get("reasoning") else None,
))
db.commit()
db.refresh(adventure)
return adventure
# ---------- Adventure scripts ----------
@router.get("/{adventure_id}/scripts", response_model=list[schemas.AdventureScriptOut])
def list_adventure_scripts(adventure_id: int, db: Session = Depends(get_db)):
return get_adventure_or_404(adventure_id, db).scripts
@router.patch(
"/{adventure_id}/scripts/{adv_script_id}", response_model=schemas.AdventureScriptOut
)
def update_adventure_script(
adventure_id: int,
adv_script_id: int,
payload: schemas.AdventureScriptUpdate,
db: Session = Depends(get_db),
):
script = db.get(models.AdventureScript, adv_script_id)
if script is None or script.adventure_id != adventure_id:
raise HTTPException(404, "Script not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(script, field, value)
db.commit()
return script
# ---------- Insights ----------
@router.get("/{adventure_id}/context")
async def dry_run_context(adventure_id: int, db: Session = Depends(get_db)):
"""What would be sent to the AI if the player continued right now."""
adventure = get_adventure_or_404(adventure_id, db)
settings = get_settings(db)
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False)
_, _, report = build_context(adventure, settings, memories)
return report
@router.get("/{adventure_id}/actions/{action_id}/context")
def action_context(adventure_id: int, action_id: int, db: Session = Depends(get_db)):
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
if action.context_snapshot is None:
raise HTTPException(404, "No context snapshot for this action")
return action.context_snapshot
# ---------- Memory bank (Phase 6) ----------
@router.get("/{adventure_id}/memories", response_model=list[schemas.MemoryOut])
def list_memories(adventure_id: int, db: Session = Depends(get_db)):
return get_adventure_or_404(adventure_id, db).memories
@router.post("/{adventure_id}/memories", response_model=schemas.MemoryOut, status_code=201)
def create_memory(
adventure_id: int, payload: schemas.MemoryCreate, db: Session = Depends(get_db)
):
"""Manually add a memory; it gets embedded by the next post-turn pass."""
adventure = get_adventure_or_404(adventure_id, db)
if not payload.text.strip():
raise HTTPException(400, "Memory text cannot be empty")
memory = models.Memory(adventure_id=adventure.id, text=payload.text.strip())
db.add(memory)
db.commit()
db.refresh(memory)
return memory
@router.patch("/{adventure_id}/memories/{memory_id}", response_model=schemas.MemoryOut)
def update_memory(
adventure_id: int,
memory_id: int,
payload: schemas.MemoryUpdate,
db: Session = Depends(get_db),
):
memory = db.get(models.Memory, memory_id)
if memory is None or memory.adventure_id != adventure_id:
raise HTTPException(404, "Memory not found")
fields = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if v is not None}
if "text" in fields and fields["text"].strip() != memory.text:
memory.embedding = None # re-embed on the next post-turn pass
for field, value in fields.items():
setattr(memory, field, value)
db.commit()
return memory
@router.delete("/{adventure_id}/memories/{memory_id}", status_code=204)
def delete_memory(adventure_id: int, memory_id: int, db: Session = Depends(get_db)):
memory = db.get(models.Memory, memory_id)
if memory is None or memory.adventure_id != adventure_id:
raise HTTPException(404, "Memory not found")
db.delete(memory)
db.commit()
# ---------- Actions (CRUD) ----------
@router.get("/{adventure_id}/actions", response_model=list[schemas.ActionOut])
def list_actions(adventure_id: int, db: Session = Depends(get_db)):
get_adventure_or_404(adventure_id, db)
return (
db.query(models.Action)
.filter(models.Action.adventure_id == adventure_id)
.order_by(models.Action.index)
.all()
)
@router.patch("/{adventure_id}/actions/{action_id}", response_model=schemas.ActionOut)
def update_action(
adventure_id: int,
action_id: int,
payload: schemas.ActionUpdate,
db: Session = Depends(get_db),
):
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
action.text = payload.text
db.commit()
return action
@router.delete("/{adventure_id}/actions/{action_id}", status_code=204)
def delete_action(adventure_id: int, action_id: int, db: Session = Depends(get_db)):
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
db.delete(action)
db.commit()
+11
View File
@@ -0,0 +1,11 @@
from fastapi import APIRouter
from .. import debuglog
router = APIRouter(prefix="/api/debug", tags=["debug"])
@router.get("/requests")
def recent_requests():
"""Most-recent-first log of provider requests/responses (no API keys)."""
return debuglog.recent()
+166
View File
@@ -0,0 +1,166 @@
from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
def get_scenario_or_404(scenario_id: int, db: Session) -> models.Scenario:
scenario = db.get(models.Scenario, scenario_id)
if scenario is None:
raise HTTPException(404, "Scenario not found")
return scenario
@router.get("", response_model=list[schemas.ScenarioListItem])
def list_scenarios(db: Session = Depends(get_db)):
return (
db.query(models.Scenario)
.order_by(models.Scenario.updated_at.desc())
.all()
)
@router.post("", response_model=schemas.ScenarioOut, status_code=201)
def create_scenario(payload: schemas.ScenarioCreate, db: Session = Depends(get_db)):
scenario = models.Scenario(**payload.model_dump())
db.add(scenario)
db.commit()
return scenario
@router.get("/{scenario_id}", response_model=schemas.ScenarioOut)
def get_scenario(scenario_id: int, db: Session = Depends(get_db)):
return get_scenario_or_404(scenario_id, db)
@router.patch("/{scenario_id}", response_model=schemas.ScenarioOut)
def update_scenario(
scenario_id: int, payload: schemas.ScenarioUpdate, db: Session = Depends(get_db)
):
scenario = get_scenario_or_404(scenario_id, db)
data = payload.model_dump(exclude_unset=True)
script_ids = data.pop("script_ids", None)
for field, value in data.items():
setattr(scenario, field, value)
if script_ids is not None:
scripts = db.query(models.Script).filter(models.Script.id.in_(script_ids)).all()
if len(scripts) != len(set(script_ids)):
raise HTTPException(404, "One or more scripts not found")
scenario.scripts = sorted(scripts, key=lambda s: script_ids.index(s.id))
db.commit()
return scenario
@router.delete("/{scenario_id}", status_code=204)
def delete_scenario(scenario_id: int, db: Session = Depends(get_db)):
scenario = get_scenario_or_404(scenario_id, db)
db.delete(scenario)
db.commit()
# ---------- Import / Export ----------
@router.get("/{scenario_id}/export")
def export_scenario(scenario_id: int, db: Session = Depends(get_db)):
s = get_scenario_or_404(scenario_id, db)
return {
"format": "ai-dnd-scenario-v1",
"title": s.title,
"description": s.description,
"prompt": s.prompt,
"memory": s.memory,
"authorsNote": s.authors_note,
"aiInstructions": s.ai_instructions,
"tags": s.tags,
"storyCards": [
{"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes}
for c in s.story_cards
],
"scripts": [
{
"name": sc.name, "description": sc.description, "library": sc.library_js,
"input": sc.input_js, "context": sc.context_js, "output": sc.output_js,
}
for sc in s.scripts
],
}
# Key aliases seen in AI Dungeon scenario exports, mapped best-effort.
_SCENARIO_KEYS = {
"title": "title",
"description": "description",
"prompt": "prompt",
"memory": "memory",
"authorsNote": "authors_note",
"authors_note": "authors_note",
"authorsNoteText": "authors_note",
"aiInstructions": "ai_instructions",
"ai_instructions": "ai_instructions",
"instructions": "ai_instructions",
}
_IGNORED_KEYS = {"format", "storyCards", "worldInfo", "worldInformation", "scripts", "tags",
"createdAt", "updatedAt", "id", "publicId", "image", "nsfw", "type", "options"}
@router.post("/import", status_code=201)
def import_scenario(bundle: dict = Body(...), db: Session = Depends(get_db)):
"""Accepts our export format and AI Dungeon scenario exports best-effort;
reports any keys it didn't understand."""
fields: dict = {}
unmapped: list[str] = []
for key, value in bundle.items():
if key in _SCENARIO_KEYS and isinstance(value, str):
fields[_SCENARIO_KEYS[key]] = value
elif key not in _IGNORED_KEYS:
unmapped.append(key)
tags = bundle.get("tags")
if isinstance(tags, list):
fields["tags"] = ", ".join(str(t) for t in tags)
elif isinstance(tags, str):
fields["tags"] = tags
scenario = models.Scenario(**fields)
if not scenario.title:
scenario.title = "Imported Scenario"
db.add(scenario)
db.flush()
cards = bundle.get("storyCards") or bundle.get("worldInfo") or []
for card in cards:
if not isinstance(card, dict):
continue
db.add(
models.StoryCard(
scenario_id=scenario.id,
type=str(card.get("type") or ""),
name=str(card.get("name") or card.get("title") or ""),
keys=str(card.get("keys") or ""),
# AI Dungeon world info uses "value"; story cards use "entry".
entry=str(card.get("entry") or card.get("value") or ""),
notes=str(card.get("notes") or card.get("description") or ""),
)
)
for item in bundle.get("scripts") or []:
if not isinstance(item, dict):
continue
script = models.Script(
name=str(item.get("name") or "Imported Script"),
description=str(item.get("description") or ""),
library_js=str(item.get("library") or item.get("sharedLibrary") or ""),
input_js=str(item.get("input") or item.get("onInput") or ""),
context_js=str(item.get("context") or item.get("onModelContext") or ""),
output_js=str(item.get("output") or item.get("onOutput") or ""),
)
db.add(script)
db.flush()
scenario.scripts.append(script)
db.commit()
out = schemas.ScenarioOut.model_validate(scenario).model_dump(mode="json")
return {"scenario": out, "unmapped_keys": unmapped}
+114
View File
@@ -0,0 +1,114 @@
from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
from ..scripting import run_hook
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
HOOK_FIELDS = {"input": "input_js", "context": "context_js", "output": "output_js"}
def get_script_or_404(script_id: int, db: Session) -> models.Script:
script = db.get(models.Script, script_id)
if script is None:
raise HTTPException(404, "Script not found")
return script
@router.get("", response_model=list[schemas.ScriptOut])
def list_scripts(db: Session = Depends(get_db)):
return db.query(models.Script).order_by(models.Script.updated_at.desc()).all()
@router.post("", response_model=schemas.ScriptOut, status_code=201)
def create_script(payload: schemas.ScriptCreate, db: Session = Depends(get_db)):
script = models.Script(**payload.model_dump())
db.add(script)
db.commit()
return script
@router.get("/{script_id}", response_model=schemas.ScriptOut)
def get_script(script_id: int, db: Session = Depends(get_db)):
return get_script_or_404(script_id, db)
@router.patch("/{script_id}", response_model=schemas.ScriptOut)
def update_script(script_id: int, payload: schemas.ScriptUpdate, db: Session = Depends(get_db)):
script = get_script_or_404(script_id, db)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(script, field, value)
db.commit()
return script
@router.delete("/{script_id}", status_code=204)
def delete_script(script_id: int, db: Session = Depends(get_db)):
db.delete(get_script_or_404(script_id, db))
db.commit()
@router.post("/{script_id}/test")
def test_script(
script_id: int, payload: schemas.ScriptTestRequest, db: Session = Depends(get_db)
):
"""Dry-run one hook against sample text — no AI call, no persistence."""
script = get_script_or_404(script_id, db)
result = run_hook(
script.library_js,
getattr(script, HOOK_FIELDS[payload.hook]),
payload.text,
payload.state,
history=[],
story_cards=[],
info={"actionCount": 0, "characterNames": [], "memoryLength": 0, "maxChars": 0},
)
return {
"text": result.text,
"stop": result.stop,
"state": result.state,
"storyCards": result.story_cards,
"logs": result.logs,
"error": result.error,
}
# ---------- Import / Export ----------
@router.get("/{script_id}/export")
def export_script(script_id: int, db: Session = Depends(get_db)):
"""JSON bundle matching how AI Dungeon scripts circulate."""
script = get_script_or_404(script_id, db)
return {
"name": script.name,
"description": script.description,
"library": script.library_js,
"input": script.input_js,
"context": script.context_js,
"output": script.output_js,
}
@router.post("/import", response_model=schemas.ScriptOut, status_code=201)
def import_script(bundle: dict = Body(...), db: Session = Depends(get_db)):
"""Accepts our export bundle; tolerates *_js key names too."""
def pick(*keys: str) -> str:
for key in keys:
value = bundle.get(key)
if isinstance(value, str):
return value
return ""
script = models.Script(
name=pick("name") or "Imported Script",
description=pick("description"),
library_js=pick("library", "library_js", "sharedLibrary"),
input_js=pick("input", "input_js", "onInput"),
context_js=pick("context", "context_js", "onModelContext"),
output_js=pick("output", "output_js", "onOutput"),
)
db.add(script)
db.commit()
return script
+57
View File
@@ -0,0 +1,57 @@
import httpx
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
router = APIRouter(prefix="/api/settings", tags=["settings"])
def get_settings(db: Session) -> models.Settings:
settings = db.get(models.Settings, 1)
if settings is None:
settings = models.Settings(id=1)
db.add(settings)
db.commit()
return settings
@router.get("", response_model=schemas.SettingsOut)
def read_settings(db: Session = Depends(get_db)):
return get_settings(db)
@router.put("", response_model=schemas.SettingsOut)
def update_settings(payload: schemas.SettingsUpdate, db: Session = Depends(get_db)):
settings = get_settings(db)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(settings, field, value)
db.commit()
return settings
@router.post("/test")
async def test_connection(db: Session = Depends(get_db)):
"""Hit the endpoint's /models listing as a cheap connectivity check."""
settings = get_settings(db)
url = settings.endpoint_url.rstrip("/") + "/models"
headers = {}
if settings.api_key:
headers["Authorization"] = f"Bearer {settings.api_key}"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url, headers=headers)
except httpx.HTTPError as exc:
return {"ok": False, "detail": f"Connection failed: {exc}"}
if resp.status_code != 200:
return {"ok": False, "detail": f"HTTP {resp.status_code}: {resp.text[:300]}"}
models_available: list[str] = []
try:
data = resp.json()
models_available = [m.get("id", "?") for m in data.get("data", [])]
except ValueError:
pass
return {"ok": True, "models": models_available}
+57
View File
@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
router = APIRouter(prefix="/api/story-cards", tags=["story-cards"])
@router.get("", response_model=list[schemas.StoryCardOut])
def list_story_cards(
scenario_id: int | None = None,
adventure_id: int | None = None,
db: Session = Depends(get_db),
):
query = db.query(models.StoryCard)
if scenario_id is not None:
query = query.filter(models.StoryCard.scenario_id == scenario_id)
if adventure_id is not None:
query = query.filter(models.StoryCard.adventure_id == adventure_id)
return query.order_by(models.StoryCard.id).all()
@router.post("", response_model=schemas.StoryCardOut, status_code=201)
def create_story_card(payload: schemas.StoryCardCreate, db: Session = Depends(get_db)):
if (payload.scenario_id is None) == (payload.adventure_id is None):
raise HTTPException(422, "Provide exactly one of scenario_id or adventure_id")
owner_model = models.Scenario if payload.scenario_id else models.Adventure
owner_id = payload.scenario_id or payload.adventure_id
if db.get(owner_model, owner_id) is None:
raise HTTPException(404, "Owner not found")
card = models.StoryCard(**payload.model_dump())
db.add(card)
db.commit()
return card
@router.patch("/{card_id}", response_model=schemas.StoryCardOut)
def update_story_card(
card_id: int, payload: schemas.StoryCardUpdate, db: Session = Depends(get_db)
):
card = db.get(models.StoryCard, card_id)
if card is None:
raise HTTPException(404, "Story card not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(card, field, value)
db.commit()
return card
@router.delete("/{card_id}", status_code=204)
def delete_story_card(card_id: int, db: Session = Depends(get_db)):
card = db.get(models.StoryCard, card_id)
if card is None:
raise HTTPException(404, "Story card not found")
db.delete(card)
db.commit()
+265
View File
@@ -0,0 +1,265 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
# ---------- Story cards ----------
class StoryCardBase(BaseModel):
type: str = ""
name: str = ""
keys: str = ""
entry: str = ""
notes: str = ""
class StoryCardCreate(StoryCardBase):
scenario_id: int | None = None
adventure_id: int | None = None
class StoryCardUpdate(BaseModel):
type: str | None = None
name: str | None = None
keys: str | None = None
entry: str | None = None
notes: str | None = None
class StoryCardOut(ORMModel, StoryCardBase):
id: int
scenario_id: int | None
adventure_id: int | None
# ---------- Scenarios ----------
class ScenarioBase(BaseModel):
title: str = "Untitled Scenario"
description: str = ""
prompt: str = ""
memory: str = ""
authors_note: str = ""
ai_instructions: str = ""
tags: str = ""
class ScenarioCreate(ScenarioBase):
pass
class ScenarioUpdate(BaseModel):
title: str | None = None
description: str | None = None
prompt: str | None = None
memory: str | None = None
authors_note: str | None = None
ai_instructions: str | None = None
tags: str | None = None
script_ids: list[int] | None = None
class ScenarioOut(ORMModel, ScenarioBase):
id: int
created_at: datetime
updated_at: datetime
story_cards: list[StoryCardOut] = []
scripts: list["ScriptOut"] = []
class ScenarioListItem(ORMModel):
id: int
title: str
description: str
tags: str
updated_at: datetime
# ---------- Adventures ----------
class AdventureCreate(BaseModel):
scenario_id: int | None = None
title: str | None = None
# ${Placeholder} values collected from the player at start (AI Dungeon behavior).
placeholders: dict[str, str] = {}
class AdventureUpdate(BaseModel):
title: str | None = None
memory: str | None = None
authors_note: str | None = None
ai_instructions: str | None = None
story_summary: str | None = None
auto_summarize: bool | None = None
memory_bank_enabled: bool | None = None
class ActionOut(ORMModel):
id: int
adventure_id: int
index: int
type: str
text: str
reasoning: str | None = None
created_at: datetime
class ActionUpdate(BaseModel):
text: str
class ActionCreate(BaseModel):
type: Literal["do", "say", "story", "continue"]
text: str = ""
class AdventureOut(ORMModel):
id: int
scenario_id: int | None
title: str
memory: str
authors_note: str
ai_instructions: str
story_summary: str
auto_summarize: bool
memory_bank_enabled: bool
created_at: datetime
updated_at: datetime
story_cards: list[StoryCardOut] = []
actions: list[ActionOut] = []
# ---------- Memory bank (Phase 6) ----------
class MemoryOut(ORMModel):
id: int
adventure_id: int
text: str
pinned: bool
forgotten: bool
embedded: bool # model property: embedding vector present
use_count: int
last_used_at: datetime | None
source_start: int | None
source_end: int | None
created_at: datetime
class MemoryCreate(BaseModel):
text: str
class MemoryUpdate(BaseModel):
text: str | None = None
pinned: bool | None = None
forgotten: bool | None = None
class AdventureListItem(ORMModel):
id: int
scenario_id: int | None
scenario_title: str | None = None
title: str
updated_at: datetime
action_count: int = 0
# ---------- Scripts ----------
class ScriptBase(BaseModel):
name: str = "Untitled Script"
description: str = ""
library_js: str = ""
input_js: str = ""
context_js: str = ""
output_js: str = ""
class ScriptCreate(ScriptBase):
pass
class ScriptUpdate(BaseModel):
name: str | None = None
description: str | None = None
library_js: str | None = None
input_js: str | None = None
context_js: str | None = None
output_js: str | None = None
class ScriptOut(ORMModel, ScriptBase):
id: int
created_at: datetime
updated_at: datetime
class ScriptTestRequest(BaseModel):
hook: Literal["input", "context", "output"]
text: str = ""
state: dict = {}
class AdventureScriptOut(ORMModel):
id: int
adventure_id: int
position: int
enabled: bool
name: str
description: str
library_js: str
input_js: str
context_js: str
output_js: str
class AdventureScriptUpdate(BaseModel):
enabled: bool | None = None
library_js: str | None = None
input_js: str | None = None
context_js: str | None = None
output_js: str | None = None
# ---------- Settings ----------
class SettingsOut(ORMModel):
endpoint_url: str
api_key: str
model: str
api_mode: str
temperature: float
max_output_tokens: int
reasoning_max_tokens: int
context_token_budget: int
narrator_prompt: str
stream: bool
summary_model: str
embedding_model: str
memory_bank_capacity: int
memory_top_k: int
ScenarioOut.model_rebuild()
class SettingsUpdate(BaseModel):
endpoint_url: str | None = None
api_key: str | None = None
model: str | None = None
api_mode: str | None = None
temperature: float | None = None
max_output_tokens: int | None = None
reasoning_max_tokens: int | None = None
context_token_budget: int | None = None
narrator_prompt: str | None = None
stream: bool | None = None
summary_model: str | None = None
embedding_model: str | None = None
memory_bank_capacity: int | None = None
memory_top_k: int | None = None
+4
View File
@@ -0,0 +1,4 @@
from .engine import HookResult, run_hook
from .pipeline import ScriptPipeline
__all__ = ["HookResult", "ScriptPipeline", "run_hook"]
+143
View File
@@ -0,0 +1,143 @@
"""AI Dungeon-compatible script execution in an embedded QuickJS sandbox.
Each hook run is fully isolated (fresh Context), capped at 16 MB memory and
2 seconds CPU, with no filesystem/network/process access (QuickJS has none by
default). Scripts follow the AI Dungeon contract: define a `modifier(text)`
and call it as the last line; its return value `{ text, stop }` is the result.
"""
import json
from dataclasses import dataclass, field
import quickjs
MEMORY_LIMIT = 16 * 1024 * 1024
TIME_LIMIT_SECONDS = 2
HISTORY_WINDOW = 100 # recent actions exposed as `history`
# Globals per the official docs: text, state, history, storyCards, info,
# log/console.log, story card functions, plus legacy worldInfo aliases.
PRELUDE = """
"use strict";
var __logs = [];
var state = __DATA__.state;
var text = __DATA__.text;
var history = __DATA__.history;
var storyCards = __DATA__.storyCards;
var info = __DATA__.info;
function log(msg) {
__logs.push(typeof msg === "string" ? msg : JSON.stringify(msg));
}
var console = { log: log };
function addStoryCard(keys, entry, type) {
for (var i = 0; i < storyCards.length; i++) {
if (storyCards[i].keys === keys) return false;
}
storyCards.push({ id: null, keys: keys || "", entry: entry || "", type: type || "" });
return storyCards.length - 1;
}
function updateStoryCard(index, keys, entry, type) {
var card = storyCards[index];
if (!card) throw new Error("Story card not found");
card.keys = keys;
card.entry = entry;
card.type = type;
}
function removeStoryCard(index) {
if (!storyCards[index]) throw new Error("Story card not found");
storyCards.splice(index, 1);
}
// Legacy aliases used by older AI Dungeon scripts.
var worldInfo = storyCards;
var worldEntries = storyCards;
function addWorldEntry(keys, entry) { return addStoryCard(keys, entry, ""); }
function updateWorldEntry(index, keys, entry) {
var card = storyCards[index];
if (!card) throw new Error("World entry not found");
card.keys = keys;
card.entry = entry;
}
function removeWorldEntry(index) { return removeStoryCard(index); }
"""
COLLECT = """
JSON.stringify({
result: (typeof __result === "undefined" || __result === null) ? null : __result,
state: state,
storyCards: storyCards,
logs: __logs
})
"""
@dataclass
class HookResult:
text: str
stop: bool = False
state: dict = field(default_factory=dict)
story_cards: list = field(default_factory=list)
logs: list = field(default_factory=list)
error: str | None = None
def run_hook(
library_js: str,
hook_js: str,
text: str,
state: dict,
history: list[dict],
story_cards: list[dict],
info: dict,
) -> HookResult:
"""Run one modifier hook. Never raises: failures come back as .error with
text/state/cards unchanged, so a bad script can't break a turn."""
unchanged = HookResult(text=text, state=state, story_cards=story_cards)
source = f"{library_js}\n;\n{hook_js}" if library_js.strip() else hook_js
if not source.strip():
return unchanged
data = {
"state": state,
"text": text,
"history": history[-HISTORY_WINDOW:],
"storyCards": story_cards,
"info": info,
}
try:
ctx = quickjs.Context()
ctx.set_memory_limit(MEMORY_LIMIT)
ctx.set_time_limit(TIME_LIMIT_SECONDS)
ctx.eval(f"var __DATA__ = {json.dumps(data)};")
ctx.eval(PRELUDE)
ctx.eval(f"var __SRC__ = {json.dumps(source)};")
# Indirect eval keeps the script in global scope, so `modifier(text)` as the
# script's final expression statement becomes the completion value.
ctx.eval("var __result = (0, eval)(__SRC__);")
collected = json.loads(ctx.eval(COLLECT))
except quickjs.JSException as exc:
unchanged.error = f"Script error: {exc}"
return unchanged
except Exception as exc: # memory limit, invalid JSON state, engine faults
unchanged.error = f"Script execution failed: {exc}"
return unchanged
result = collected.get("result")
new_text, stop = text, False
if isinstance(result, dict):
if isinstance(result.get("text"), str):
new_text = result["text"]
stop = bool(result.get("stop"))
elif isinstance(result, str):
new_text = result
new_state = collected.get("state")
return HookResult(
text=new_text,
stop=stop,
state=new_state if isinstance(new_state, dict) else {},
story_cards=collected.get("storyCards") or [],
logs=collected.get("logs") or [],
)
+95
View File
@@ -0,0 +1,95 @@
"""Runs an adventure's enabled scripts through a turn's hook points, applying
state and story-card mutations back to the database after each hook."""
from sqlalchemy.orm import Session
from .. import models
from .engine import run_hook
MAX_STORY_CARDS = 5000 # AI Dungeon's per-adventure sanity cap
class ScriptPipeline:
def __init__(self, adventure: models.Adventure, db: Session):
self.adventure = adventure
self.db = db
self.logs: list[str] = []
self.errors: list[str] = []
@property
def message(self) -> str | None:
state = self.adventure.script_state
msg = state.get("message") if isinstance(state, dict) else None
return msg if isinstance(msg, str) and msg.strip() else None
def _history(self) -> list[dict]:
return [
{"text": a.text, "rawText": a.text, "type": a.type}
for a in self.adventure.actions
]
def _cards(self) -> list[dict]:
return [
{"id": c.id, "keys": c.keys, "entry": c.entry, "type": c.type}
for c in self.adventure.story_cards
]
def _info(self) -> dict:
return {
"actionCount": len(self.adventure.actions),
"characterNames": [],
"memoryLength": len(self.adventure.memory),
"maxChars": 0,
}
def _apply_cards(self, returned: list) -> None:
existing = {c.id: c for c in self.adventure.story_cards}
seen_ids = set()
for item in returned:
if not isinstance(item, dict):
continue
card_id = item.get("id")
keys = str(item.get("keys") or "")
entry = str(item.get("entry") or "")
card_type = str(item.get("type") or "")
if card_id in existing:
seen_ids.add(card_id)
card = existing[card_id]
card.keys, card.entry, card.type = keys, entry, card_type
elif len(existing) + len(seen_ids) < MAX_STORY_CARDS:
self.db.add(
models.StoryCard(
adventure_id=self.adventure.id,
keys=keys, entry=entry, type=card_type,
)
)
for card_id, card in existing.items():
if card_id not in seen_ids:
self.db.delete(card)
def run(self, hook: str, text: str) -> tuple[str, bool]:
"""Chain `hook` across all enabled scripts. Returns (text, stop)."""
state = self.adventure.script_state if isinstance(self.adventure.script_state, dict) else {}
for script in self.adventure.scripts:
hook_js = getattr(script, f"{hook}_js")
if not script.enabled or not hook_js.strip():
continue
result = run_hook(
script.library_js, hook_js, text, state,
self._history(), self._cards(), self._info(),
)
if result.error:
self.errors.append(f"{script.name} ({hook}): {result.error}")
continue # a broken script never breaks the turn
self.logs.extend(f"[{script.name}/{hook}] {line}" for line in result.logs)
self._apply_cards(result.story_cards)
state = result.state
self.adventure.script_state = state
self.db.commit()
text = result.text
if result.stop:
return text, True
return text, False
def report(self) -> dict:
return {"logs": self.logs, "errors": self.errors, "message": self.message}
+7
View File
@@ -0,0 +1,7 @@
fastapi>=0.115
uvicorn[standard]>=0.30
sqlalchemy>=2.0
pydantic>=2.7
httpx>=0.27
tiktoken>=0.7
quickjs>=1.19
+250
View File
@@ -0,0 +1,250 @@
"""Seed a demo scenario + adventure with sample scripts for testing.
Run from the backend folder: .venv\\Scripts\\python.exe seed_demo.py
Safe to rerun: it deletes any previous rows titled "[Demo] ..." first.
"""
from app import models, migrations
from app.database import SessionLocal, engine
# create_all + user_version stamp; plain create_all would leave a fresh DB at
# version 0 and the server would replay every ALTER TABLE migration on start.
migrations.bootstrap(engine)
DEMO_PREFIX = "[Demo]"
# ---------------------------------------------------------------------------
# Sample scripts — AI Dungeon contract: define modifier(text), call it last.
# ---------------------------------------------------------------------------
DICE_ROLLER = dict(
name=f"{DEMO_PREFIX} Dice Roller",
description=(
"Input hook + shared library. Type '!roll 2d6' or '!roll d20' in a Do/Say/"
"Story action and the command is replaced with the rolled result."
),
library_js="""\
// Shared library: available to every hook of this script.
function rollDice(count, sides) {
var total = 0, rolls = [];
for (var i = 0; i < count; i++) {
var r = Math.floor(Math.random() * sides) + 1;
rolls.push(r);
total += r;
}
return { total: total, rolls: rolls };
}
""",
input_js="""\
const modifier = (text) => {
// Replace every "!roll NdS" (N optional) with the roll result.
var out = text.replace(/!roll\\s+(\\d*)d(\\d+)/gi, function (m, n, s) {
var count = parseInt(n || "1", 10);
var sides = parseInt(s, 10);
var res = rollDice(count, sides);
log("Rolled " + count + "d" + sides + ": [" + res.rolls.join(", ") + "] = " + res.total);
return "(rolled " + count + "d" + sides + ": " + res.total + ")";
});
return { text: out };
};
modifier(text);
""",
)
TURN_TRACKER = dict(
name=f"{DEMO_PREFIX} Turn & HP Tracker",
description=(
"Demonstrates persistent state. Counts turns; '!hp -3' or '!hp +5' in input "
"adjusts HP (starts at 20). Current stats appear in state.message."
),
input_js="""\
const modifier = (text) => {
if (state.hp === undefined) state.hp = 20;
state.turns = (state.turns || 0) + 1;
var out = text.replace(/!hp\\s*([+-]\\d+)/gi, function (m, delta) {
state.hp += parseInt(delta, 10);
return "";
});
state.message = "Turn " + state.turns + " | HP: " + state.hp + "/20";
log(state.message);
if (state.hp <= 0) {
// stop:true ends the turn before the AI is called.
return { text: out + "\\n\\nYou have fallen. (HP reached 0 — turn stopped by script.)", stop: true };
}
return { text: out };
};
modifier(text);
""",
)
CONTEXT_INSPECTOR = dict(
name=f"{DEMO_PREFIX} Context Inspector",
description=(
"Context hook: logs the size of the assembled context each turn and appends "
"a style directive. Check the logs/context in the action's context snapshot."
),
context_js="""\
const modifier = (text) => {
log("Context size: " + text.length + " chars, actions so far: " + info.actionCount
+ ", story cards: " + storyCards.length);
// Anything returned here replaces what is sent to the model.
return { text: text + "\\n[Style: keep the response under three paragraphs.]" };
};
modifier(text);
""",
)
OUTPUT_POLISH = dict(
name=f"{DEMO_PREFIX} Output Polish + Card Discovery",
description=(
"Output hook: trims a trailing incomplete sentence from the AI reply, and "
"auto-creates a story card the first time the ghost Vharos is mentioned."
),
output_js="""\
const modifier = (text) => {
var out = text;
// Drop a trailing sentence fragment (no ending punctuation).
var m = out.match(/^([\\s\\S]*[.!?"'\\u2026])[^.!?"'\\u2026]*$/);
if (m && m[1].length > 40) {
if (m[1].length < out.length) log("Trimmed incomplete final sentence.");
out = m[1];
}
// Demonstrate script-created story cards.
if (/vharos/i.test(out)) {
var added = addStoryCard(
"Vharos, ghost, spirit",
"Vharos was the crypt's architect, now a restless ghost bound to the amulet he was buried with. He speaks in echoes and cannot lie.",
"character"
);
if (added !== false) log("Vharos mentioned — story card created.");
}
return { text: out };
};
modifier(text);
""",
)
SCRIPTS = [DICE_ROLLER, TURN_TRACKER, CONTEXT_INSPECTOR, OUTPUT_POLISH]
# ---------------------------------------------------------------------------
# Scenario content
# ---------------------------------------------------------------------------
SCENARIO = dict(
title=f"{DEMO_PREFIX} The Sunken Crypt of Vharos",
description=(
"A short dungeon-crawl demo scenario with story cards and one of each "
"script hook, for testing the app end to end."
),
prompt=(
"Rain hammers the moors as you descend the moss-slick steps beneath the "
"ruined chapel. Your torch gutters in the stale air. Below, the Sunken "
"Crypt of Vharos waits — its iron door ajar, as if someone (or something) "
"expected you. Mira's warning rings in your ears: bring back the Ember "
"Amulet before nightfall, or the village of Hollowmere burns.\n\n"
"You stand before the iron door, water pooling around your boots."
),
memory=(
"The player is an adventurer hired by Mira, blacksmith of Hollowmere, to "
"retrieve the Ember Amulet from the Sunken Crypt of Vharos before "
"nightfall. The crypt is flooded, dark, and haunted. Tone: classic D&D "
"dungeon crawl, dangerous but fair."
),
authors_note="Keep scenes tense and grounded; offer clear choices; consequences matter.",
ai_instructions=(
"Write in second person, present tense. End each response at a moment "
"where the player can act."
),
tags="demo, dungeon, fantasy, short",
)
STORY_CARDS = [
dict(
type="character",
name="Mira the Blacksmith",
keys="Mira, blacksmith",
entry=(
"Mira is Hollowmere's blacksmith: broad-shouldered, gray-braided, "
"practical. She hired the player and paid half up front. She knows "
"more about the crypt than she has admitted."
),
notes="Secretly a descendant of Vharos.",
),
dict(
type="location",
name="The Sunken Crypt",
keys="crypt, tomb, Vharos",
entry=(
"A flooded burial complex beneath a ruined chapel. Knee-deep black "
"water, collapsed pillars, and phosphorescent moss. Three chambers: "
"the Drowned Hall, the Ossuary, and the sealed Reliquary where the "
"Ember Amulet rests."
),
notes="",
),
dict(
type="item",
name="The Ember Amulet",
keys="amulet, ember",
entry=(
"A fist-sized garnet on a bronze chain that glows like a coal. It "
"keeps Hollowmere's protective hearth-ward burning. Touching it bare-"
"handed brands the flesh but does no lasting harm."
),
notes="",
),
]
# ---------------------------------------------------------------------------
db = SessionLocal()
try:
# Remove earlier demo rows so reruns stay clean.
for adv in db.query(models.Adventure).filter(models.Adventure.title.like(f"{DEMO_PREFIX}%")):
db.delete(adv)
for sc in db.query(models.Scenario).filter(models.Scenario.title.like(f"{DEMO_PREFIX}%")):
db.delete(sc)
for s in db.query(models.Script).filter(models.Script.name.like(f"{DEMO_PREFIX}%")):
db.delete(s)
db.commit()
# Script library
scripts = [models.Script(**s) for s in SCRIPTS]
db.add_all(scripts)
# Scenario with cards and scripts attached
scenario = models.Scenario(**SCENARIO)
scenario.scripts = scripts
db.add(scenario)
db.flush()
for card in STORY_CARDS:
db.add(models.StoryCard(scenario_id=scenario.id, **card))
# Adventure created from the scenario, mirroring POST /api/adventures
adventure = models.Adventure(
scenario_id=scenario.id,
title=scenario.title,
memory=scenario.memory,
authors_note=scenario.authors_note,
ai_instructions=scenario.ai_instructions,
)
db.add(adventure)
db.flush()
for card in STORY_CARDS:
db.add(models.StoryCard(adventure_id=adventure.id, **card))
for position, s in enumerate(SCRIPTS):
db.add(models.AdventureScript(adventure_id=adventure.id, position=position, **s))
db.add(models.Action(adventure_id=adventure.id, index=0, type="start", text=scenario.prompt))
db.commit()
print(f"Scenario id={scenario.id}: {scenario.title}")
print(f"Adventure id={adventure.id}: {adventure.title}")
print(f"Scripts: {', '.join(s.name for s in scripts)}")
finally:
db.close()
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=Crimson+Pro:ital,wght@0,400;0,500;0,600;1,400&family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<title>AI D&amp;D</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1643
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/theme-one-dark": "^6.1.3",
"@uiw/react-codemirror": "^4.25.10",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"vite": "^8.1.1"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

+24
View File
@@ -0,0 +1,24 @@
import { NavLink, Outlet } from 'react-router-dom'
export default function App() {
return (
<>
<nav className="topnav">
<span className="brand">⚔ AI D&D</span>
<NavLink to="/" end className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Adventures
</NavLink>
<NavLink to="/scenarios" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Scenarios
</NavLink>
<NavLink to="/scripts" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Scripts
</NavLink>
<NavLink to="/settings" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Settings
</NavLink>
</nav>
<Outlet />
</>
)
}
+115
View File
@@ -0,0 +1,115 @@
async function request(path, options = {}) {
const resp = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!resp.ok) {
let detail = resp.statusText
try {
const body = await resp.json()
detail = body.detail || detail
} catch { /* non-JSON error body */ }
throw new Error(detail)
}
if (resp.status === 204) return null
return resp.json()
}
// POSTs to an SSE endpoint and dispatches events: {type: 'player'|'chunk'|'done'|'error', ...}
async function streamSSE(path, payload, onEvent, signal) {
const resp = await fetch(`/api${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal,
})
if (!resp.ok) {
let detail = resp.statusText
try { detail = (await resp.json()).detail || detail } catch { /* non-JSON */ }
throw new Error(detail)
}
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split('\n\n')
buffer = events.pop() // keep incomplete tail
for (const block of events) {
for (const line of block.split('\n')) {
if (line.startsWith('data:')) onEvent(JSON.parse(line.slice(5)))
}
}
}
}
export const api = {
// Scenarios
listScenarios: () => request('/scenarios'),
getScenario: (id) => request(`/scenarios/${id}`),
createScenario: (data) => request('/scenarios', { method: 'POST', body: JSON.stringify(data) }),
updateScenario: (id, data) => request(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteScenario: (id) => request(`/scenarios/${id}`, { method: 'DELETE' }),
// Adventures
listAdventures: () => request('/adventures'),
getAdventure: (id) => request(`/adventures/${id}`),
createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }),
updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }),
updateAction: (advId, actionId, text) =>
request(`/adventures/${advId}/actions/${actionId}`, { method: 'PATCH', body: JSON.stringify({ text }) }),
deleteAction: (advId, actionId) =>
request(`/adventures/${advId}/actions/${actionId}`, { method: 'DELETE' }),
sendAction: (advId, payload, handlers, signal) =>
streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal),
retry: (advId, handlers, signal) => streamSSE(`/adventures/${advId}/retry`, {}, handlers, signal),
exportAdventure: (id) => request(`/adventures/${id}/export`),
importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }),
undo: (advId) => request(`/adventures/${advId}/undo`, { method: 'POST' }),
getAdventureContext: (advId) => request(`/adventures/${advId}/context`),
getActionContext: (advId, actionId) => request(`/adventures/${advId}/actions/${actionId}/context`),
// Memory bank
listMemories: (advId) => request(`/adventures/${advId}/memories`),
createMemory: (advId, text) =>
request(`/adventures/${advId}/memories`, { method: 'POST', body: JSON.stringify({ text }) }),
updateMemory: (advId, memoryId, data) =>
request(`/adventures/${advId}/memories/${memoryId}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteMemory: (advId, memoryId) =>
request(`/adventures/${advId}/memories/${memoryId}`, { method: 'DELETE' }),
listAdventureScripts: (advId) => request(`/adventures/${advId}/scripts`),
updateAdventureScript: (advId, scriptId, data) =>
request(`/adventures/${advId}/scripts/${scriptId}`, { method: 'PATCH', body: JSON.stringify(data) }),
// Scripts
listScripts: () => request('/scripts'),
getScript: (id) => request(`/scripts/${id}`),
createScript: (data) => request('/scripts', { method: 'POST', body: JSON.stringify(data) }),
updateScript: (id, data) => request(`/scripts/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteScript: (id) => request(`/scripts/${id}`, { method: 'DELETE' }),
testScript: (id, data) => request(`/scripts/${id}/test`, { method: 'POST', body: JSON.stringify(data) }),
exportScript: (id) => request(`/scripts/${id}/export`),
importScript: (bundle) => request('/scripts/import', { method: 'POST', body: JSON.stringify(bundle) }),
// Scenario import/export
exportScenario: (id) => request(`/scenarios/${id}/export`),
importScenario: (bundle) => request('/scenarios/import', { method: 'POST', body: JSON.stringify(bundle) }),
// Story cards
createStoryCard: (data) => request('/story-cards', { method: 'POST', body: JSON.stringify(data) }),
updateStoryCard: (id, data) => request(`/story-cards/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteStoryCard: (id) => request(`/story-cards/${id}`, { method: 'DELETE' }),
// Debug
getDebugRequests: () => request('/debug/requests'),
// Settings
getSettings: () => request('/settings'),
updateSettings: (data) => request('/settings', { method: 'PUT', body: JSON.stringify(data) }),
testConnection: () => request('/settings/test', { method: 'POST' }),
}
+113
View File
@@ -0,0 +1,113 @@
import { useState } from 'react'
export function downloadJSON(obj, filename) {
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export function pickJSONFile() {
return new Promise((resolve, reject) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json,application/json'
input.onchange = () => {
const file = input.files[0]
if (!file) return reject(new Error('No file selected'))
const reader = new FileReader()
reader.onload = () => {
try { resolve(JSON.parse(reader.result)) }
catch { reject(new Error('Not valid JSON')) }
}
reader.onerror = () => reject(new Error('Could not read file'))
reader.readAsText(file)
}
input.click()
})
}
// Unique ${Placeholder} names, in order of first appearance, across the given texts.
export function extractPlaceholders(...texts) {
const names = []
for (const text of texts) {
for (const match of (text || '').matchAll(/\$\{([^}]+)\}/g)) {
const name = match[1].trim()
if (name && !names.includes(name)) names.push(name)
}
}
return names
}
export function PlaceholderModal({ title, names, onSubmit, onCancel }) {
const [values, setValues] = useState(Object.fromEntries(names.map((n) => [n, ''])))
const submit = (e) => {
e.preventDefault()
onSubmit(values)
}
return (
<div className="modal-overlay" onClick={onCancel}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h2>{title}</h2>
<p className="modal-hint">This scenario asks a few questions before you begin.</p>
{names.map((name, i) => (
<label key={name} className="field">
<span className="label">{name}</span>
<input
type="text"
autoFocus={i === 0}
value={values[name]}
onChange={(e) => setValues({ ...values, [name]: e.target.value })}
/>
</label>
))}
<div className="modal-buttons">
<button type="button" onClick={onCancel}>Cancel</button>
<button type="submit" className="primary">Begin Adventure</button>
</div>
</form>
</div>
)
}
export function Field({ label, value, onChange, textarea, rows, placeholder }) {
return (
<label className="field">
<span className="label">{label}</span>
{textarea ? (
<textarea rows={rows || 3} value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
) : (
<input type="text" value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
)}
</label>
)
}
export function StoryCardRow({ card, onChange, onDelete }) {
return (
<div className="storycard">
<div className="row">
<input type="text" placeholder="Name (not sent to AI)" value={card.name}
onChange={(e) => onChange({ ...card, name: e.target.value })} />
<input type="text" placeholder="Type (e.g. Character)" value={card.type}
onChange={(e) => onChange({ ...card, type: e.target.value })} />
</div>
<div className="row">
<input type="text" placeholder="Triggers, comma-separated" value={card.keys}
onChange={(e) => onChange({ ...card, keys: e.target.value })} />
</div>
<textarea rows={2} placeholder="Entry — sent to the AI when a trigger matches" value={card.entry}
onChange={(e) => onChange({ ...card, entry: e.target.value })} />
<div style={{ textAlign: 'right', marginTop: 6 }}>
<button className="danger" style={{ padding: '3px 10px', fontSize: '0.78rem' }} onClick={onDelete}>
Remove
</button>
</div>
</div>
)
}
+677
View File
@@ -0,0 +1,677 @@
:root {
--bg: #0a0a0f;
--bg-panel: #131320;
--bg-panel-glass: rgba(19, 19, 32, 0.82);
--bg-input: #1a1a2a;
--border: #2b2b3d;
--border-bright: #3d3d55;
--text: #e2ddd0;
--text-dim: #918c7d;
--accent: #d4a94e;
--accent-bright: #e8c476;
--accent-dim: #96773a;
--accent-glow: rgba(212, 169, 78, 0.25);
--danger: #d06565;
--player: #9fc7d1;
--font-display: 'Cinzel', Georgia, serif;
--font-story: 'Crimson Pro', Georgia, 'Times New Roman', serif;
--font-ui: 'Inter', 'Segoe UI', system-ui, sans-serif;
font-family: var(--font-ui);
color-scheme: dark;
}
* { box-sizing: border-box; }
body {
margin: 0;
background:
radial-gradient(1200px 700px at 15% -10%, rgba(212, 169, 78, 0.06), transparent 60%),
radial-gradient(1000px 600px at 90% 110%, rgba(88, 76, 140, 0.08), transparent 55%),
var(--bg);
background-attachment: fixed;
color: var(--text);
min-height: 100vh;
}
#root { min-height: 100vh; display: flex; flex-direction: column; }
h1, h2, h3 { font-weight: 600; letter-spacing: 0.03em; }
a { color: var(--accent); text-decoration: none; transition: color 0.15s; }
a:hover { color: var(--accent-bright); }
::selection { background: var(--accent-dim); color: #0f0d08; }
/* ---------- Scrollbars ---------- */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border-bright);
border-radius: 5px;
border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); }
/* ---------- Top navigation ---------- */
.topnav {
display: flex;
align-items: center;
gap: 26px;
padding: 0 28px;
height: 56px;
background: var(--bg-panel-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
position: sticky;
top: 0;
z-index: 10;
}
.topnav .brand {
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.14em;
text-shadow: 0 0 18px var(--accent-glow);
margin-right: 6px;
}
.topnav a.navlink {
color: var(--text-dim);
padding: 17px 2px 15px;
font-size: 0.9rem;
font-weight: 500;
letter-spacing: 0.04em;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.2s;
}
.topnav a.navlink.active {
color: var(--accent-bright);
border-bottom-color: var(--accent);
text-shadow: 0 0 14px var(--accent-glow);
}
.topnav a.navlink:hover { color: var(--text); }
.page {
flex: 1;
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 28px 24px;
animation: page-in 0.35s ease both;
}
@keyframes page-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
/* ---------- Forms ---------- */
label.field { display: block; margin-bottom: 14px; }
label.field .label {
display: block;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-dim);
margin-bottom: 5px;
}
input[type="text"], input[type="password"], input[type="number"], textarea, select {
width: 100%;
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 12px;
font-size: 0.95rem;
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--accent-dim);
box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.12);
}
input::placeholder, textarea::placeholder { color: #5c584d; }
button {
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 7px 16px;
font-size: 0.88rem;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, transform 0.1s;
}
button:hover {
border-color: var(--accent-dim);
box-shadow: 0 0 12px rgba(212, 169, 78, 0.1);
}
button:active { transform: translateY(1px); }
button.primary {
background: linear-gradient(160deg, var(--accent-bright), var(--accent) 55%, #b78d3e);
border-color: var(--accent);
color: #191307;
font-weight: 600;
letter-spacing: 0.02em;
box-shadow: 0 2px 10px var(--accent-glow);
}
button.primary:hover {
box-shadow: 0 2px 18px rgba(212, 169, 78, 0.45);
filter: brightness(1.08);
}
button.danger { color: var(--danger); }
button.danger:hover { border-color: var(--danger); box-shadow: 0 0 12px rgba(208, 101, 101, 0.15); }
button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: none; }
/* ---------- Cards & lists ---------- */
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.card {
position: relative;
background: linear-gradient(170deg, #17172400, #17172400 60%, rgba(212, 169, 78, 0.03)), var(--bg-panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-dim), transparent);
opacity: 0;
transition: opacity 0.25s;
}
.card:hover {
border-color: var(--border-bright);
transform: translateY(-3px);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45), 0 0 20px rgba(212, 169, 78, 0.06);
}
.card:hover::before { opacity: 1; }
.card h3 {
margin: 0 0 6px;
font-family: var(--font-display);
font-size: 1.02rem;
color: var(--accent);
}
.card p { margin: 0; color: var(--text-dim); font-size: 0.88rem; line-height: 1.5; }
.card .meta { margin-top: 12px; font-size: 0.75rem; color: var(--text-dim); }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.page-header h1 {
margin: 0;
font-family: var(--font-display);
font-size: 1.55rem;
color: var(--accent-bright);
text-shadow: 0 0 24px var(--accent-glow);
}
.empty { color: var(--text-dim); text-align: center; padding: 60px 0; font-style: italic; font-family: var(--font-story); font-size: 1.05rem; }
/* ---------- Story card editor ---------- */
.storycard {
background: var(--bg-input);
border: 1px solid var(--border);
border-left: 3px solid var(--accent-dim);
border-radius: 8px;
padding: 12px;
margin-bottom: 10px;
transition: border-color 0.15s;
}
.storycard:focus-within { border-color: var(--accent-dim); }
.storycard .row { display: flex; gap: 10px; margin-bottom: 8px; }
.storycard .row > * { flex: 1; }
/* ---------- Play page ---------- */
.play-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
.story {
flex: 1;
font-family: var(--font-story);
font-size: 1.14rem;
line-height: 1.7;
white-space: pre-wrap;
padding-bottom: 16px;
}
.story .action {
position: relative;
margin-bottom: 15px;
padding-right: 56px;
animation: action-in 0.4s ease both;
}
@keyframes action-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.story .action.player {
color: var(--player);
border-left: 2px solid rgba(159, 199, 209, 0.35);
padding-left: 14px;
font-style: italic;
}
.story .action.streaming { color: var(--text); }
.story .cursor {
color: var(--accent-bright);
text-shadow: 0 0 8px var(--accent-glow);
animation: blink 1s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
/* Reasoning-model thinking, collapsed above the AI's response */
.story .reasoning {
margin: 0 0 8px;
font-size: 0.82rem;
line-height: 1.5;
color: var(--text-dim);
font-style: normal;
}
.story .reasoning summary {
cursor: pointer;
user-select: none;
color: var(--text-dim);
opacity: 0.8;
}
.story .reasoning summary:hover { opacity: 1; }
.story .reasoning .reasoning-text {
margin-top: 6px;
padding: 8px 12px;
border-left: 2px solid var(--border);
opacity: 0.85;
max-height: 260px;
overflow-y: auto;
}
.story .action-tools {
position: absolute;
top: 0;
right: 0;
display: inline-flex;
gap: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.story .action:hover .action-tools { opacity: 1; pointer-events: auto; }
.story .action-tools button {
padding: 1px 8px;
font-size: 0.72rem;
font-family: var(--font-ui);
color: var(--text-dim);
background: var(--bg-panel-glass);
backdrop-filter: blur(6px);
}
.story .action-tools button:hover { color: var(--text); }
.action-edit { margin-bottom: 14px; }
.action-edit textarea { min-height: 110px; font-family: inherit; font-size: inherit; }
.action-edit .edit-buttons { display: flex; gap: 8px; margin-top: 6px; }
.play-controls {
position: sticky;
bottom: 0;
background: linear-gradient(to top, var(--bg) 75%, transparent);
padding: 18px 0 16px;
border-top: 1px solid var(--border);
}
.turn-buttons { display: flex; gap: 8px; margin-bottom: 10px; }
.turn-buttons button { font-size: 0.78rem; padding: 4px 12px; color: var(--text-dim); }
.turn-buttons button:hover:not(:disabled) { color: var(--text); }
.input-bar { display: flex; gap: 8px; align-items: stretch; }
.input-bar input { flex: 1; }
.mode-select { display: flex; }
.mode-select button {
border-radius: 0;
border-right-width: 0;
padding: 7px 14px;
color: var(--text-dim);
text-transform: capitalize;
}
.mode-select button:first-child { border-radius: 8px 0 0 8px; }
.mode-select button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; }
.mode-select button.active {
color: var(--accent-bright);
background: var(--bg-panel);
border-color: var(--accent-dim);
border-right-width: 1px;
box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08);
}
.mode-select button.active + button { border-left-width: 0; }
/* ---------- Side panel (Plot / Insights) ---------- */
.play-layout { flex: 1; display: flex; min-height: calc(100vh - 56px); }
.play-layout .play-page { max-width: 960px; }
.play-layout.with-panel .play-page { max-width: none; }
.panel-toggles { display: flex; gap: 8px; }
.panel-toggles button.active {
color: var(--accent-bright);
border-color: var(--accent-dim);
box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08);
}
.side-panel {
width: 420px;
flex-shrink: 0;
border-left: 1px solid var(--border);
background: var(--bg-panel);
padding: 20px;
overflow-y: auto;
height: calc(100vh - 56px);
position: sticky;
top: 56px;
animation: panel-in 0.25s ease both;
}
@keyframes panel-in {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: none; }
}
.side-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.side-panel-header h2 {
margin: 0;
font-family: var(--font-display);
font-size: 1.05rem;
color: var(--accent);
}
/* ---------- Insights ---------- */
.insights-meta { margin-bottom: 16px; font-size: 0.85rem; }
.insights-mode { color: var(--text-dim); margin-bottom: 8px; }
.insights-mode button { padding: 2px 8px; font-size: 0.75rem; }
.token-total { font-size: 1rem; color: var(--text); margin-bottom: 4px; }
.token-total.over { color: var(--danger); }
.token-bar { height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; margin-bottom: 8px; }
.token-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-dim), var(--accent-bright));
box-shadow: 0 0 8px var(--accent-glow);
border-radius: 3px;
transition: width 0.4s ease;
}
.insights-history { color: var(--text-dim); margin-bottom: 8px; }
.insights-cards div { margin-bottom: 3px; }
.insights-cards .dropped { color: var(--danger); }
.ctx-section { margin-bottom: 12px; border-left: 3px solid var(--border); background: rgba(255, 255, 255, 0.015); border-radius: 0 6px 6px 0; }
.ctx-section .ctx-header {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
padding: 5px 10px 3px;
}
.ctx-section pre {
margin: 0;
padding: 6px 10px 8px;
white-space: pre-wrap;
word-break: break-word;
font-family: Consolas, monospace;
font-size: 0.8rem;
line-height: 1.5;
color: var(--text);
max-height: 260px;
overflow-y: auto;
}
.ctx-narrator, .ctx-ai_instructions { border-left-color: #7d8fc9; }
.ctx-narrator .ctx-header, .ctx-ai_instructions .ctx-header { color: #7d8fc9; }
.ctx-plot_essentials, .ctx-script_context { border-left-color: #c97dc0; }
.ctx-plot_essentials .ctx-header, .ctx-script_context .ctx-header { color: #c97dc0; }
.ctx-story_summary { border-left-color: #7dc9a2; }
.ctx-story_summary .ctx-header { color: #7dc9a2; }
.ctx-world_lore { border-left-color: #c9b47d; }
.ctx-world_lore .ctx-header { color: #c9b47d; }
.ctx-history, .ctx-recent_history { border-left-color: #6a6a78; }
.ctx-authors_note, .ctx-front_memory { border-left-color: #c97d7d; }
.ctx-authors_note .ctx-header, .ctx-front_memory .ctx-header { color: #c97d7d; }
/* ---------- Scripts ---------- */
.slot-tabs { display: flex; gap: 0; margin-bottom: 4px; }
.slot-tabs button {
border-radius: 0;
border-right-width: 0;
color: var(--text-dim);
}
.slot-tabs button:first-child { border-radius: 8px 0 0 8px; }
.slot-tabs button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; }
.slot-tabs button.active { color: var(--accent-bright); background: var(--bg-panel); }
.slot-hint { color: var(--text-dim); font-size: 0.8rem; margin-bottom: 8px; }
.cm-editor { border: 1px solid var(--border); border-radius: 8px; font-size: 0.88rem; }
.cm-editor.cm-focused {
outline: none;
border-color: var(--accent-dim);
box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.1);
}
.test-run .row { display: flex; gap: 12px; }
.test-result {
margin-top: 12px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
font-size: 0.85rem;
}
.test-result .label {
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.08em;
color: var(--text-dim);
margin-right: 6px;
}
.test-result pre {
display: inline-block;
margin: 2px 0;
white-space: pre-wrap;
word-break: break-word;
font-family: Consolas, monospace;
vertical-align: top;
}
.test-error { color: var(--danger); white-space: pre-wrap; }
.script-attach {
display: flex;
align-items: baseline;
gap: 8px;
padding: 9px 12px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 6px;
cursor: pointer;
transition: border-color 0.15s;
}
.script-attach:hover { border-color: var(--border-bright); }
.script-attach .dim { color: var(--text-dim); font-size: 0.82rem; }
/* ---------- Memory bank (Phase 6) ---------- */
.memory-row {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 12px;
margin-bottom: 6px;
}
.memory-row.forgotten { opacity: 0.55; }
.memory-text { font-size: 0.9rem; white-space: pre-wrap; }
.memory-meta {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 10px;
margin-top: 5px;
font-size: 0.78rem;
}
.memory-meta .dim { color: var(--text-dim); }
.memory-meta .action-tools { display: inline-flex; gap: 4px; margin-left: auto; }
.memory-meta .action-tools button {
padding: 1px 8px;
font-size: 0.72rem;
color: var(--text-dim);
}
.memory-meta .action-tools button:hover { color: var(--text); }
.memory-badge {
color: var(--accent);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0 8px;
}
.script-report { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 8px; }
.script-error { color: var(--danger); font-size: 0.85rem; margin-bottom: 4px; white-space: pre-wrap; }
.script-logs {
background: var(--bg-input);
border-radius: 8px;
padding: 8px 10px;
font-family: Consolas, monospace;
font-size: 0.8rem;
white-space: pre-wrap;
word-break: break-word;
margin: 6px 0;
}
/* ---------- Filter bar & tags ---------- */
.filter-bar { margin-bottom: 18px; }
.search-input { max-width: 340px; }
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.tag {
padding: 2px 12px;
font-size: 0.76rem;
border-radius: 999px;
color: var(--text-dim);
}
.tag.active {
color: var(--accent-bright);
border-color: var(--accent-dim);
box-shadow: inset 0 0 10px rgba(212, 169, 78, 0.1);
}
.tag.small {
display: inline-block;
background: var(--bg-input);
border: 1px solid var(--border);
padding: 1px 9px;
margin-right: 4px;
cursor: default;
}
/* ---------- Modal ---------- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(5, 5, 9, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
animation: page-in 0.2s ease both;
}
.modal {
width: min(480px, calc(100vw - 40px));
max-height: 80vh;
overflow-y: auto;
background: var(--bg-panel);
border: 1px solid var(--border-bright);
border-radius: 14px;
padding: 26px 28px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(212, 169, 78, 0.06);
}
.modal h2 {
margin: 0 0 4px;
font-family: var(--font-display);
font-size: 1.2rem;
color: var(--accent-bright);
}
.modal .modal-hint { margin: 0 0 18px; color: var(--text-dim); font-size: 0.88rem; }
.modal .modal-buttons { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
/* ---------- Debug log (Settings) ---------- */
.debug-entry {
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 8px;
overflow: hidden;
}
.debug-entry-head {
display: flex;
align-items: baseline;
gap: 12px;
padding: 9px 12px;
cursor: pointer;
font-size: 0.86rem;
background: var(--bg-input);
}
.debug-entry-head .dim, .debug-entry-body .dim { color: var(--text-dim); font-size: 0.8rem; }
.debug-status {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 1px 8px;
border-radius: 999px;
border: 1px solid var(--border-bright);
color: var(--text-dim);
}
.debug-status.ok { color: #7dc9a2; border-color: #7dc9a244; }
.debug-status.error { color: var(--danger); border-color: var(--danger); }
.debug-status.pending { color: var(--accent); border-color: var(--accent-dim); }
.debug-entry-body { padding: 10px 12px; font-size: 0.85rem; }
.debug-entry-body pre {
margin: 2px 0 8px;
padding: 8px 10px;
background: var(--bg-input);
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
font-family: Consolas, monospace;
font-size: 0.78rem;
max-height: 300px;
overflow-y: auto;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-panel-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--danger);
color: var(--text);
padding: 11px 20px;
border-radius: 10px;
z-index: 100;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
animation: toast-in 0.25s ease both;
}
.toast.ok { border-color: var(--accent); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow); }
@keyframes toast-in {
from { opacity: 0; transform: translate(-50%, 12px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
+34
View File
@@ -0,0 +1,34 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import App from './App.jsx'
import Home from './pages/Home.jsx'
import Scenarios from './pages/Scenarios.jsx'
import ScenarioEditor from './pages/ScenarioEditor.jsx'
import Play from './pages/Play.jsx'
import Scripts from './pages/Scripts.jsx'
import ScriptEditor from './pages/ScriptEditor.jsx'
import Settings from './pages/Settings.jsx'
import './index.css'
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{ index: true, element: <Home /> },
{ path: 'scenarios', element: <Scenarios /> },
{ path: 'scenarios/:id', element: <ScenarioEditor /> },
{ path: 'play/:id', element: <Play /> },
{ path: 'scripts', element: <Scripts /> },
{ path: 'scripts/:id', element: <ScriptEditor /> },
{ path: 'settings', element: <Settings /> },
],
},
])
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
)
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { downloadJSON, pickJSONFile } from '../components'
export default function Home() {
const [adventures, setAdventures] = useState(null)
const [search, setSearch] = useState('')
const navigate = useNavigate()
useEffect(() => {
api.listAdventures().then(setAdventures).catch(() => setAdventures([]))
}, [])
const visible = useMemo(() => {
if (!adventures) return null
const q = search.trim().toLowerCase()
if (!q) return adventures
return adventures.filter((a) =>
`${a.title} ${a.scenario_title || ''}`.toLowerCase().includes(q))
}, [adventures, search])
const remove = async (e, id) => {
e.stopPropagation()
if (!confirm('Delete this adventure permanently?')) return
await api.deleteAdventure(id)
setAdventures(adventures.filter((a) => a.id !== id))
}
const exportOne = async (e, adv) => {
e.stopPropagation()
const bundle = await api.exportAdventure(adv.id)
const safe = adv.title.replace(/[^\w-]+/g, '_').slice(0, 60) || 'adventure'
downloadJSON(bundle, `${safe}.json`)
}
const importOne = async () => {
try {
const bundle = await pickJSONFile()
const adv = await api.importAdventure(bundle)
navigate(`/play/${adv.id}`)
} catch (err) {
alert(err.message)
}
}
return (
<div className="page">
<div className="page-header">
<h1>Adventures</h1>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={importOne}>Import</button>
<button className="primary" onClick={() => navigate('/scenarios')}>
+ New Adventure
</button>
</div>
</div>
{adventures?.length > 0 && (
<div className="filter-bar">
<input
type="text"
className="search-input"
placeholder="Search adventures…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
)}
{visible === null ? null : visible.length === 0 ? (
<div className="empty">
{adventures.length === 0
? 'No adventures yet. Head to Scenarios to begin your first story.'
: 'No adventures match your search.'}
</div>
) : (
<div className="card-grid">
{visible.map((adv) => (
<div key={adv.id} className="card" onClick={() => navigate(`/play/${adv.id}`)}>
<h3>{adv.title}</h3>
<p>
{adv.scenario_title ? `From “${adv.scenario_title}” · ` : ''}
{adv.action_count} actions
</p>
<div className="meta">
Last played {new Date(adv.updated_at + 'Z').toLocaleString()}
<span style={{ float: 'right', display: 'inline-flex', gap: 4 }}>
<button
title="Export as JSON backup"
style={{ padding: '2px 8px', fontSize: '0.75rem' }}
onClick={(e) => exportOne(e, adv)}
>
Export
</button>
<button
className="danger"
style={{ padding: '2px 8px', fontSize: '0.75rem' }}
onClick={(e) => remove(e, adv.id)}
>
Delete
</button>
</span>
</div>
</div>
))}
</div>
)}
</div>
)
}
+706
View File
@@ -0,0 +1,706 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { Field, StoryCardRow } from '../components'
const MODES = ['do', 'say', 'story']
const PLAYER_TYPES = ['do', 'say', 'story']
// Models often emit light markdown emphasis; render **bold** / *italic*
// instead of showing raw asterisks. Everything else stays plain text.
function renderEmphasis(text) {
const re = /\*\*([^*\n]+)\*\*|\*([^*\n]+)\*/g
const parts = []
let last = 0
let match
while ((match = re.exec(text)) !== null) {
if (match.index > last) parts.push(text.slice(last, match.index))
parts.push(match[1] !== undefined
? <b key={match.index}>{match[1]}</b>
: <i key={match.index}>{match[2]}</i>)
last = match.index + match[0].length
}
if (parts.length === 0) return text
if (last < text.length) parts.push(text.slice(last))
return parts
}
function ReasoningBlock({ text, streaming }) {
if (!text) return null
return (
<details className="reasoning" open={streaming || undefined}>
<summary>💭 Reasoning{streaming ? '…' : ''}</summary>
<div className="reasoning-text">{text}</div>
</details>
)
}
const SECTION_LABELS = {
narrator: 'Narrator prompt',
script_context: 'Script context',
ai_instructions: 'AI Instructions',
plot_essentials: 'Plot Essentials',
story_summary: 'Story Summary',
used_memories: 'Used Memories (memory bank)',
world_lore: 'World Lore (story cards)',
history: 'Story history',
authors_note: "Author's Note",
recent_history: 'Recent history',
front_memory: 'Front memory',
}
function PlotPanel({ adventure, setAdventure }) {
const saveTimer = useRef(null)
const setField = (field, value) => {
setAdventure({ ...adventure, [field]: value })
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => api.updateAdventure(adventure.id, { [field]: value }), 600)
}
const addCard = async () => {
const card = await api.createStoryCard({ adventure_id: adventure.id })
setAdventure({ ...adventure, story_cards: [...adventure.story_cards, card] })
}
const updateCard = (card) => {
setAdventure({
...adventure,
story_cards: adventure.story_cards.map((c) => (c.id === card.id ? card : c)),
})
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => {
api.updateStoryCard(card.id, {
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
})
}, 600)
}
const deleteCard = async (cardId) => {
await api.deleteStoryCard(cardId)
setAdventure({
...adventure,
story_cards: adventure.story_cards.filter((c) => c.id !== cardId),
})
}
return (
<div>
<Field label="Plot Essentials (Memory)" value={adventure.memory}
onChange={(v) => setField('memory', v)} textarea
placeholder="Key facts the AI should always remember." />
<Field label="Author's Note" value={adventure.authors_note}
onChange={(v) => setField('authors_note', v)} textarea rows={2}
placeholder="Style/theme guidance, injected near the end of context." />
<Field label="AI Instructions" value={adventure.ai_instructions}
onChange={(v) => setField('ai_instructions', v)} textarea rows={2}
placeholder="Behavioral guidance for the model." />
<Field label="Story Summary" value={adventure.story_summary}
onChange={(v) => setField('story_summary', v)} textarea
placeholder="Running summary of events so far. Updated automatically every 15 actions when auto-summarization is on; your edits are kept as the base for the next update." />
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Story Cards</h3>
<button onClick={addCard}>+ Add</button>
</div>
{adventure.story_cards.length === 0 && (
<div className="empty" style={{ padding: '12px 0' }}>No story cards yet.</div>
)}
{adventure.story_cards.map((card) => (
<StoryCardRow key={card.id} card={card}
onChange={updateCard} onDelete={() => deleteCard(card.id)} />
))}
</div>
)
}
function MemoryRow({ memory, onChange, onDelete }) {
const [editText, setEditText] = useState(null)
const save = () => {
const text = editText.trim()
setEditText(null)
if (text && text !== memory.text) onChange({ text })
}
return (
<div className={`memory-row ${memory.forgotten ? 'forgotten' : ''}`}>
{editText !== null ? (
<div className="action-edit" style={{ margin: 0 }}>
<textarea autoFocus value={editText} rows={3}
onChange={(e) => setEditText(e.target.value)} />
<div className="edit-buttons">
<button className="primary" onClick={save}>Save</button>
<button onClick={() => setEditText(null)}>Cancel</button>
</div>
</div>
) : (
<>
<div className="memory-text">{memory.text}</div>
<div className="memory-meta">
{memory.pinned && <span className="memory-badge">📌 pinned</span>}
{memory.forgotten && <span className="memory-badge">forgotten</span>}
{!memory.embedded && !memory.forgotten && (
<span className="memory-badge" title="Embedded on the next turn">not embedded yet</span>
)}
{memory.source_start != null && (
<span className="dim">actions {memory.source_start}–{memory.source_end}</span>
)}
<span className="dim">used {memory.use_count}×</span>
<span className="action-tools">
<button title={memory.pinned ? 'Unpin' : 'Pin (always included in context)'}
onClick={() => onChange({ pinned: !memory.pinned })}>📌</button>
{memory.forgotten && (
<button title="Restore this memory"
onClick={() => onChange({ forgotten: false })}>↩</button>
)}
<button title="Edit" onClick={() => setEditText(memory.text)}>✎</button>
<button title="Delete" onClick={onDelete}>✕</button>
</span>
</div>
</>
)}
</div>
)
}
function MemoryPanel({ adventure, setAdventure, refreshKey }) {
const [memories, setMemories] = useState(null)
const [newText, setNewText] = useState('')
const load = useCallback(() => {
api.listMemories(adventure.id).then(setMemories).catch(() => setMemories([]))
}, [adventure.id])
// Summarization runs in the background after a turn, so also refresh shortly after.
useEffect(() => {
load()
const timer = setTimeout(load, 4000)
return () => clearTimeout(timer)
}, [load, refreshKey])
const setFlag = async (field, value) => {
setAdventure({ ...adventure, [field]: value })
await api.updateAdventure(adventure.id, { [field]: value })
}
const change = async (memory, data) => {
const updated = await api.updateMemory(adventure.id, memory.id, data)
setMemories((prev) => prev.map((m) => (m.id === memory.id ? updated : m)))
}
const remove = async (memory) => {
await api.deleteMemory(adventure.id, memory.id)
setMemories((prev) => prev.filter((m) => m.id !== memory.id))
}
const add = async () => {
const text = newText.trim()
if (!text) return
setNewText('')
const memory = await api.createMemory(adventure.id, text)
setMemories((prev) => [...prev, memory])
}
const active = memories?.filter((m) => !m.forgotten) ?? []
const forgotten = memories?.filter((m) => m.forgotten) ?? []
return (
<div>
<label className="script-attach">
<input type="checkbox" checked={adventure.auto_summarize}
onChange={(e) => setFlag('auto_summarize', e.target.checked)} />
<span>Auto-summarization</span>
<span className="dim">memories every 6 actions, Story Summary every 15</span>
</label>
<label className="script-attach">
<input type="checkbox" checked={adventure.memory_bank_enabled}
onChange={(e) => setFlag('memory_bank_enabled', e.target.checked)} />
<span>Memory Bank</span>
<span className="dim">retrieve relevant memories into context (needs an embedding model)</span>
</label>
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Memories {memories && `(${active.length})`}</h3>
<button onClick={load}>Refresh</button>
</div>
{!memories && <div className="empty" style={{ padding: '12px 0' }}>Loading…</div>}
{memories && active.length === 0 && (
<div className="empty" style={{ padding: '12px 0' }}>
No memories yet. They are generated automatically as you play (from action 12
onward), or add one below.
</div>
)}
{active.map((m) => (
<MemoryRow key={m.id} memory={m}
onChange={(data) => change(m, data)} onDelete={() => remove(m)} />
))}
<div className="input-bar" style={{ marginTop: 10 }}>
<input type="text" value={newText} placeholder="Add a memory manually…"
onChange={(e) => setNewText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') add() }} />
<button onClick={add}>+ Add</button>
</div>
{forgotten.length > 0 && (
<>
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Forgotten ({forgotten.length})</h3>
</div>
{forgotten.map((m) => (
<MemoryRow key={m.id} memory={m}
onChange={(data) => change(m, data)} onDelete={() => remove(m)} />
))}
</>
)}
</div>
)
}
function ScriptsPanel({ advId }) {
const [scripts, setScripts] = useState(null)
useEffect(() => {
api.listAdventureScripts(advId).then(setScripts).catch(() => setScripts([]))
}, [advId])
const toggle = async (script) => {
const updated = await api.updateAdventureScript(advId, script.id, { enabled: !script.enabled })
setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s)))
}
if (!scripts) return <div className="empty">Loading…</div>
if (scripts.length === 0) {
return (
<div className="empty">
No scripts on this adventure. Attach scripts to a scenario before starting an
adventure from it.
</div>
)
}
return (
<div>
{scripts.map((s) => (
<label key={s.id} className="script-attach">
<input type="checkbox" checked={s.enabled} onChange={() => toggle(s)} />
<span>{s.name}</span>
<span className="dim">{s.description}</span>
</label>
))}
</div>
)
}
function ScriptReport({ script }) {
if (!script || (!script.logs?.length && !script.errors?.length && !script.context_changed)) {
return null
}
return (
<div className="script-report">
<div className="ctx-header" style={{ padding: '4px 0 2px' }}><span>Scripts</span></div>
{script.errors?.map((e, i) => <div key={i} className="script-error">⚠ {e}</div>)}
{script.logs?.length > 0 && (
<pre className="script-logs">{script.logs.join('\n')}</pre>
)}
{script.context_changed && (
<>
<div className="ctx-section ctx-script_context">
<div className="ctx-header"><span>Context before script</span></div>
<pre>{script.context_before}</pre>
</div>
<div className="ctx-section ctx-script_context">
<div className="ctx-header"><span>Context after script (sent to AI)</span></div>
<pre>{script.context_after}</pre>
</div>
</>
)}
</div>
)
}
function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
const [report, setReport] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
setError(null)
const load = inspectActionId
? api.getActionContext(advId, inspectActionId)
: api.getAdventureContext(advId)
load.then(setReport).catch((err) => { setReport(null); setError(err.message) })
}, [advId, inspectActionId, refreshKey])
if (error) return <div className="empty">{error}</div>
if (!report) return <div className="empty">Loading…</div>
const { tokens, cards, history, sections } = report
const overBudget = tokens.total > tokens.budget
return (
<div className="insights">
<div className="insights-meta">
{inspectActionId ? (
<div className="insights-mode">
Snapshot of a past turn
<button onClick={onClearInspect} style={{ marginLeft: 8 }}>Back to next turn</button>
</div>
) : (
<div className="insights-mode">What will be sent on the next turn</div>
)}
<div className={`token-total ${overBudget ? 'over' : ''}`}>
{tokens.total} / {tokens.budget} tokens
</div>
<div className="token-bar">
<div className="token-bar-fill"
style={{ width: `${Math.min(100, (tokens.total / tokens.budget) * 100)}%` }} />
</div>
<div className="insights-history">
History: {history.included} of {history.total} actions in context
{history.total > history.included && ' (older history trimmed)'}
{history.oldest_truncated && ' — oldest entry cut mid-text'}
</div>
{cards.length > 0 && (
<div className="insights-cards">
{cards.map((c, i) => (
<div key={i} className={c.included ? '' : 'dropped'}>
▸ <b>{c.name || '(unnamed card)'}</b> triggered on “{c.keyword}”
{!c.included && ' — dropped (over card budget)'}
</div>
))}
</div>
)}
{report.memories && (
<div className="insights-cards">
{report.memories.error && (
<div className="dropped">⚠ Memory bank: {report.memories.error}</div>
)}
{report.memories.used?.map((m, i) => (
<div key={i}>
▸ memory retrieved ({m.pinned ? 'pinned' : `similarity ${m.similarity.toFixed(2)}`}):
{' '}{m.text.length > 90 ? m.text.slice(0, 90) + '…' : m.text}
</div>
))}
{!report.memories.error && report.memories.used?.length === 0 && (
<div className="dim">Memory bank on — no memories retrieved.</div>
)}
</div>
)}
</div>
{sections.map((s, i) => (
<div key={i} className={`ctx-section ctx-${s.label}`}>
<div className="ctx-header">
<span>{SECTION_LABELS[s.label] || s.label}</span>
<span>{s.tokens} tok</span>
</div>
<pre>{s.text}</pre>
</div>
))}
<ScriptReport script={report.script} />
</div>
)
}
export default function Play() {
const { id } = useParams()
const navigate = useNavigate()
const [adventure, setAdventure] = useState(null)
const [actions, setActions] = useState([])
const [mode, setMode] = useState('do')
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(null)
const [reasoningStream, setReasoningStream] = useState(null)
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState(null)
const [editing, setEditing] = useState(null)
const [panel, setPanel] = useState(null) // null | 'plot' | 'insights'
const [inspectActionId, setInspectActionId] = useState(null)
const storyEndRef = useRef(null)
const abortRef = useRef(null)
const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom
useEffect(() => {
api.getAdventure(id)
.then((adv) => { setAdventure(adv); setActions(adv.actions) })
.catch(() => navigate('/'))
}, [id, navigate])
useEffect(() => {
const onScroll = () => {
pinnedRef.current =
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 120
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
useEffect(() => {
if (pinnedRef.current) {
storyEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}
}, [actions, streaming, reasoningStream])
const handleScriptReport = useCallback((script) => {
if (!script) return
if (script.errors?.length) {
setToast({ text: `Script error: ${script.errors[0]}`, isError: true })
} else if (script.message) {
setToast({ text: script.message, isError: false })
}
}, [])
const handleEvent = useCallback((event) => {
if (event.type === 'player') {
setActions((prev) => [...prev, event.action])
} else if (event.type === 'chunk') {
setStreaming((prev) => (prev ?? '') + event.text)
} else if (event.type === 'reasoning') {
setReasoningStream((prev) => (prev ?? '') + event.text)
} else if (event.type === 'done') {
setStreaming(null)
setReasoningStream(null)
setActions((prev) => [...prev, event.action])
handleScriptReport(event.script)
} else if (event.type === 'stopped') {
setStreaming(null)
setReasoningStream(null)
handleScriptReport(event.script)
} else if (event.type === 'error') {
setStreaming(null)
setReasoningStream(null)
setToast({ text: event.detail, isError: true })
}
}, [handleScriptReport])
async function runTurn(run) {
const controller = new AbortController()
abortRef.current = controller
setBusy(true)
setToast(null)
setStreaming('')
pinnedRef.current = true
try {
await run(controller.signal)
} catch (err) {
if (err.name === 'AbortError') {
setToast({ text: 'Generation stopped.', isError: false })
} else {
setToast({ text: err.message, isError: true })
}
} finally {
abortRef.current = null
setStreaming(null)
setReasoningStream(null)
setBusy(false)
}
}
function stopGeneration() {
abortRef.current?.abort()
}
function send(type = mode) {
const text = input.trim()
const payload = { type: text ? type : 'continue', text }
setInput('')
runTurn((signal) => api.sendAction(id, payload, handleEvent, signal))
}
function retry() {
setActions((prev) =>
prev.length && prev[prev.length - 1].type === 'ai' ? prev.slice(0, -1) : prev)
runTurn((signal) => api.retry(id, handleEvent, signal))
}
async function undo() {
setToast(null)
try {
setActions(await api.undo(id))
} catch (err) {
setToast({ text: err.message, isError: true })
}
}
// Ctrl+Z undo / Ctrl+R retry, ignored while typing in a field.
useEffect(() => {
const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai'
const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start'
const onKey = (e) => {
if (!(e.ctrlKey || e.metaKey) || busy) return
if (e.target.closest?.('input, textarea, select, [contenteditable]')) return
if (e.key.toLowerCase() === 'z' && canUndo) {
e.preventDefault()
undo()
} else if (e.key.toLowerCase() === 'r' && lastIsAi) {
e.preventDefault()
retry()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
})
async function saveEdit() {
const { id: actionId, text } = editing
setEditing(null)
try {
const updated = await api.updateAction(id, actionId, text)
setActions((prev) => prev.map((a) => (a.id === actionId ? updated : a)))
} catch (err) {
setToast({ text: err.message, isError: true })
}
}
async function removeAction(actionId) {
try {
await api.deleteAction(id, actionId)
setActions((prev) => prev.filter((a) => a.id !== actionId))
} catch (err) {
setToast({ text: err.message, isError: true })
}
}
function inspect(actionId) {
setInspectActionId(actionId)
setPanel('insights')
}
if (!adventure) return null
const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai'
const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start'
return (
<div className={`play-layout ${panel ? 'with-panel' : ''}`}>
<div className="page play-page">
<div className="page-header">
<h1>{adventure.title}</h1>
<div className="panel-toggles">
<button className={panel === 'plot' ? 'active' : ''}
onClick={() => setPanel(panel === 'plot' ? null : 'plot')}>Plot</button>
<button className={panel === 'memory' ? 'active' : ''}
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
<button className={panel === 'scripts' ? 'active' : ''}
onClick={() => setPanel(panel === 'scripts' ? null : 'scripts')}>Scripts</button>
<button className={panel === 'insights' ? 'active' : ''}
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
Insights
</button>
</div>
</div>
<div className="story">
{actions.length === 0 && streaming === null && (
<div className="empty">A blank page. Type something below to begin your story.</div>
)}
{actions.map((action) =>
editing?.id === action.id ? (
<div key={action.id} className="action-edit">
<textarea
autoFocus
value={editing.text}
onChange={(e) => setEditing({ ...editing, text: e.target.value })}
/>
<div className="edit-buttons">
<button className="primary" onClick={saveEdit}>Save</button>
<button onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
) : (
<div key={action.id}
className={`action ${PLAYER_TYPES.includes(action.type) ? 'player' : ''}`}>
<ReasoningBlock text={action.reasoning} />
{renderEmphasis(action.text)}
{!busy && (
<span className="action-tools">
{action.type === 'ai' && (
<button title="View the exact prompt that produced this"
onClick={() => inspect(action.id)}>🔍</button>
)}
<button title="Edit"
onClick={() => setEditing({ id: action.id, text: action.text })}>✎</button>
<button title="Delete" onClick={() => removeAction(action.id)}>✕</button>
</span>
)}
</div>
)
)}
{streaming !== null && (
<div className="action streaming">
<ReasoningBlock text={reasoningStream} streaming />
{renderEmphasis(streaming)}
<span className="cursor">▋</span>
</div>
)}
<div ref={storyEndRef} />
</div>
<div className="play-controls">
<div className="turn-buttons">
<button onClick={() => send('continue')} disabled={busy}>Continue ▸</button>
<button onClick={retry} disabled={busy || !lastIsAi} title="Ctrl+R">↻ Retry</button>
<button onClick={undo} disabled={busy || !canUndo} title="Ctrl+Z">↶ Undo</button>
</div>
<div className="input-bar">
<div className="mode-select">
{MODES.map((m) => (
<button key={m} className={mode === m ? 'active' : ''}
onClick={() => setMode(m)} disabled={busy}>
{m[0].toUpperCase() + m.slice(1)}
</button>
))}
</div>
<input
type="text"
value={input}
disabled={busy}
placeholder={
mode === 'do' ? 'What do you do?'
: mode === 'say' ? 'What do you say?'
: 'What happens next?'
}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !busy) send() }}
/>
{busy ? (
<button className="danger" onClick={stopGeneration}>■ Stop</button>
) : (
<button className="primary" onClick={() => send()}>Send</button>
)}
</div>
</div>
</div>
{panel && (
<div className="side-panel">
<div className="side-panel-header">
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', scripts: 'Scripts', insights: 'Insights' }[panel]}</h2>
<button onClick={() => setPanel(null)}>✕</button>
</div>
{panel === 'plot' ? (
<PlotPanel adventure={adventure} setAdventure={setAdventure} />
) : panel === 'memory' ? (
<MemoryPanel adventure={adventure} setAdventure={setAdventure}
refreshKey={actions.length} />
) : panel === 'scripts' ? (
<ScriptsPanel advId={id} />
) : (
<InsightsPanel advId={id} inspectActionId={inspectActionId}
onClearInspect={() => setInspectActionId(null)} refreshKey={actions.length} />
)}
</div>
)}
{toast && (
<div className={`toast ${toast.isError ? '' : 'ok'}`}>
{toast.text}
{toast.isError && (
<button style={{ marginLeft: 12 }} onClick={retry}>Retry</button>
)}
<button style={{ marginLeft: 8 }} onClick={() => setToast(null)}>✕</button>
</div>
)}
</div>
)
}
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { Field, StoryCardRow, downloadJSON } from '../components'
export default function ScenarioEditor() {
const { id } = useParams()
const navigate = useNavigate()
const [scenario, setScenario] = useState(null)
const [allScripts, setAllScripts] = useState([])
const [status, setStatus] = useState('')
const saveTimer = useRef(null)
useEffect(() => {
api.getScenario(id).then(setScenario).catch(() => navigate('/scenarios'))
api.listScripts().then(setAllScripts).catch(() => {})
}, [id, navigate])
const setField = (field, value) => {
const next = { ...scenario, [field]: value }
setScenario(next)
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(async () => {
await api.updateScenario(id, { [field]: value })
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
}, 600)
}
const addCard = async () => {
const card = await api.createStoryCard({ scenario_id: Number(id) })
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
}
const updateCard = (card) => {
setScenario({
...scenario,
story_cards: scenario.story_cards.map((c) => (c.id === card.id ? card : c)),
})
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => {
api.updateStoryCard(card.id, {
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
})
}, 600)
}
const deleteCard = async (cardId) => {
await api.deleteStoryCard(cardId)
setScenario({ ...scenario, story_cards: scenario.story_cards.filter((c) => c.id !== cardId) })
}
const toggleScript = async (scriptId) => {
const current = scenario.scripts.map((s) => s.id)
const next = current.includes(scriptId)
? current.filter((sid) => sid !== scriptId)
: [...current, scriptId]
const updated = await api.updateScenario(id, { script_ids: next })
setScenario(updated)
}
const exportScenario = async () => {
downloadJSON(await api.exportScenario(id), `${scenario.title.replace(/\W+/g, '-')}.json`)
}
const deleteScenario = async () => {
if (!confirm('Delete this scenario permanently?')) return
await api.deleteScenario(id)
navigate('/scenarios')
}
const play = async () => {
const adv = await api.createAdventure({ scenario_id: Number(id) })
navigate(`/play/${adv.id}`)
}
if (!scenario) return null
return (
<div className="page">
<div className="page-header">
<h1>Edit Scenario</h1>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{status}</span>
<button onClick={exportScenario}>Export</button>
<button className="danger" onClick={deleteScenario}>Delete</button>
<button className="primary" onClick={play}>Play</button>
</div>
</div>
<Field label="Title" value={scenario.title} onChange={(v) => setField('title', v)} />
<Field label="Description" value={scenario.description} onChange={(v) => setField('description', v)}
textarea placeholder="Shown in the scenario list; not sent to the AI." />
<Field label="Opening Prompt" value={scenario.prompt} onChange={(v) => setField('prompt', v)}
textarea rows={6} placeholder="The opening story text. Supports ${placeholders} (Phase 5)." />
<Field label="Plot Essentials (Memory)" value={scenario.memory} onChange={(v) => setField('memory', v)}
textarea placeholder="Key facts the AI should always remember." />
<Field label="Author's Note" value={scenario.authors_note} onChange={(v) => setField('authors_note', v)}
textarea rows={2} placeholder="Style/theme guidance, injected near the end of context." />
<Field label="AI Instructions" value={scenario.ai_instructions} onChange={(v) => setField('ai_instructions', v)}
textarea rows={2} placeholder="Behavioral guidance for the model (always included)." />
<Field label="Tags" value={scenario.tags} onChange={(v) => setField('tags', v)} placeholder="fantasy, mystery" />
<div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Story Cards</h2>
<button onClick={addCard}>+ Add Card</button>
</div>
{scenario.story_cards.length === 0 && (
<div className="empty" style={{ padding: '20px 0' }}>
No story cards. Cards inject their Entry into context when a trigger word appears in the story.
</div>
)}
{scenario.story_cards.map((card) => (
<StoryCardRow key={card.id} card={card}
onChange={updateCard} onDelete={() => deleteCard(card.id)} />
))}
<div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Attached Scripts</h2>
</div>
{allScripts.length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
No scripts in your library. Create some on the Scripts page.
</div>
) : (
allScripts.map((s) => (
<label key={s.id} className="script-attach">
<input
type="checkbox"
checked={scenario.scripts.some((att) => att.id === s.id)}
onChange={() => toggleScript(s.id)}
/>
<span>{s.name}</span>
<span className="dim">{s.description}</span>
</label>
))
)}
</div>
)
}
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { extractPlaceholders, pickJSONFile, PlaceholderModal } from '../components'
function splitTags(tags) {
return (tags || '').split(',').map((t) => t.trim()).filter(Boolean)
}
export default function Scenarios() {
const [scenarios, setScenarios] = useState(null)
const [search, setSearch] = useState('')
const [tagFilter, setTagFilter] = useState(null)
const [pending, setPending] = useState(null) // { scenario, names } awaiting placeholder values
const navigate = useNavigate()
useEffect(() => {
api.listScenarios().then(setScenarios).catch(() => setScenarios([]))
}, [])
const allTags = useMemo(() => {
const tags = new Set()
for (const sc of scenarios || []) splitTags(sc.tags).forEach((t) => tags.add(t))
return [...tags].sort()
}, [scenarios])
const visible = useMemo(() => {
if (!scenarios) return null
const q = search.trim().toLowerCase()
return scenarios.filter((sc) => {
if (tagFilter && !splitTags(sc.tags).includes(tagFilter)) return false
if (q && !`${sc.title} ${sc.description} ${sc.tags}`.toLowerCase().includes(q)) return false
return true
})
}, [scenarios, search, tagFilter])
const createScenario = async () => {
const scenario = await api.createScenario({ title: 'New Scenario' })
navigate(`/scenarios/${scenario.id}`)
}
const begin = async (scenarioId, placeholders = {}) => {
const adv = await api.createAdventure({ scenario_id: scenarioId, placeholders })
navigate(`/play/${adv.id}`)
}
const startAdventure = async (e, scenarioId) => {
e.stopPropagation()
const scenario = await api.getScenario(scenarioId)
const names = extractPlaceholders(
scenario.prompt, scenario.memory, scenario.authors_note, scenario.ai_instructions,
...scenario.story_cards.map((c) => c.entry),
)
if (names.length === 0) return begin(scenarioId)
setPending({ scenario, names })
}
const startBlank = async () => {
const adv = await api.createAdventure({ title: 'Blank Adventure' })
navigate(`/play/${adv.id}`)
}
return (
<div className="page">
<div className="page-header">
<h1>Scenarios</h1>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={startBlank}>Blank Adventure</button>
<button onClick={async () => {
try {
const bundle = await pickJSONFile()
const { scenario, unmapped_keys } = await api.importScenario(bundle)
if (unmapped_keys.length) alert(`Imported. Unmapped fields ignored: ${unmapped_keys.join(', ')}`)
navigate(`/scenarios/${scenario.id}`)
} catch (err) { alert(err.message) }
}}>Import</button>
<button className="primary" onClick={createScenario}>+ New Scenario</button>
</div>
</div>
<div className="filter-bar">
<input
type="text"
className="search-input"
placeholder="Search scenarios…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{allTags.length > 0 && (
<div className="tag-row">
{allTags.map((tag) => (
<button
key={tag}
className={`tag ${tagFilter === tag ? 'active' : ''}`}
onClick={() => setTagFilter(tagFilter === tag ? null : tag)}
>
{tag}
</button>
))}
</div>
)}
</div>
{visible === null ? null : visible.length === 0 ? (
<div className="empty">
{scenarios.length === 0
? 'No scenarios yet. Create one to define a reusable story template.'
: 'No scenarios match your search.'}
</div>
) : (
<div className="card-grid">
{visible.map((sc) => (
<div key={sc.id} className="card" onClick={() => navigate(`/scenarios/${sc.id}`)}>
<h3>{sc.title}</h3>
<p>{sc.description || 'No description'}</p>
<div className="meta">
{splitTags(sc.tags).map((tag) => (
<span key={tag} className="tag small">{tag}</span>
))}
<button
className="primary"
style={{ float: 'right', padding: '3px 10px', fontSize: '0.78rem' }}
onClick={(e) => startAdventure(e, sc.id)}
>
Play
</button>
</div>
</div>
))}
</div>
)}
{pending && (
<PlaceholderModal
title={pending.scenario.title}
names={pending.names}
onCancel={() => setPending(null)}
onSubmit={(values) => { setPending(null); begin(pending.scenario.id, values) }}
/>
)}
</div>
)
}
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { oneDark } from '@codemirror/theme-one-dark'
import { api } from '../api'
import { Field, downloadJSON } from '../components'
const SLOTS = [
{ key: 'library_js', label: 'Library', hint: 'Shared code prepended to all three hooks.' },
{ key: 'input_js', label: 'Input', hint: "onInput — modifies the player's input before context construction." },
{ key: 'context_js', label: 'Context', hint: 'onModelContext — modifies the assembled text sent to the model.' },
{ key: 'output_js', label: 'Output', hint: 'onOutput — modifies the model output before it is shown.' },
]
export default function ScriptEditor() {
const { id } = useParams()
const navigate = useNavigate()
const [script, setScript] = useState(null)
const [slot, setSlot] = useState('input_js')
const [status, setStatus] = useState('')
const saveTimer = useRef(null)
// Test-run state
const [testHook, setTestHook] = useState('input')
const [testText, setTestText] = useState('> You look around.')
const [testState, setTestState] = useState('{}')
const [testResult, setTestResult] = useState(null)
useEffect(() => {
api.getScript(id).then(setScript).catch(() => navigate('/scripts'))
}, [id, navigate])
const setField = (field, value) => {
setScript((prev) => ({ ...prev, [field]: value }))
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(async () => {
await api.updateScript(id, { [field]: value })
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
}, 600)
}
const runTest = async () => {
let state
try {
state = JSON.parse(testState || '{}')
} catch {
setTestResult({ error: 'Test state is not valid JSON' })
return
}
try {
setTestResult(await api.testScript(id, { hook: testHook, text: testText, state }))
} catch (err) {
setTestResult({ error: err.message })
}
}
const exportScript = async () => {
downloadJSON(await api.exportScript(id), `${script.name.replace(/\W+/g, '-')}.json`)
}
const deleteScript = async () => {
if (!confirm('Delete this script permanently?')) return
await api.deleteScript(id)
navigate('/scripts')
}
if (!script) return null
const activeSlot = SLOTS.find((s) => s.key === slot)
return (
<div className="page">
<div className="page-header">
<h1>Edit Script</h1>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{status}</span>
<button onClick={exportScript}>Export</button>
<button className="danger" onClick={deleteScript}>Delete</button>
</div>
</div>
<Field label="Name" value={script.name} onChange={(v) => setField('name', v)} />
<Field label="Description" value={script.description} onChange={(v) => setField('description', v)}
textarea rows={2} placeholder="What this script does." />
<div className="slot-tabs">
{SLOTS.map((s) => (
<button key={s.key} className={slot === s.key ? 'active' : ''} onClick={() => setSlot(s.key)}>
{s.label}{script[s.key].trim() ? ' •' : ''}
</button>
))}
</div>
<div className="slot-hint">{activeSlot.hint}</div>
<CodeMirror
value={script[slot]}
height="360px"
theme={oneDark}
extensions={[javascript()]}
onChange={(value) => setField(slot, value)}
/>
<div className="page-header" style={{ marginTop: 26 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Test Run</h2>
</div>
<div className="test-run">
<div className="row">
<label className="field" style={{ flex: '0 0 140px' }}>
<span className="label">Hook</span>
<select value={testHook} onChange={(e) => setTestHook(e.target.value)}>
<option value="input">Input</option>
<option value="context">Context</option>
<option value="output">Output</option>
</select>
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Sample text</span>
<input type="text" value={testText} onChange={(e) => setTestText(e.target.value)} />
</label>
</div>
<label className="field">
<span className="label">State (JSON)</span>
<input type="text" value={testState} onChange={(e) => setTestState(e.target.value)} />
</label>
<button className="primary" onClick={runTest}>Run</button>
{testResult && (
<div className="test-result">
{testResult.error ? (
<div className="test-error">{testResult.error}</div>
) : (
<>
<div><span className="label">text</span> <pre>{testResult.text}</pre></div>
{testResult.stop && <div><span className="label">stop</span> true — the AI call would be skipped</div>}
<div><span className="label">state</span> <pre>{JSON.stringify(testResult.state)}</pre></div>
{testResult.storyCards.length > 0 && (
<div><span className="label">storyCards</span> <pre>{JSON.stringify(testResult.storyCards, null, 1)}</pre></div>
)}
{testResult.logs.length > 0 && (
<div><span className="label">logs</span> <pre>{testResult.logs.join('\n')}</pre></div>
)}
</>
)}
</div>
)}
</div>
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { downloadJSON, pickJSONFile } from '../components'
export default function Scripts() {
const [scripts, setScripts] = useState(null)
const navigate = useNavigate()
useEffect(() => {
api.listScripts().then(setScripts).catch(() => setScripts([]))
}, [])
const createScript = async () => {
const script = await api.createScript({
name: 'New Script',
input_js: '// onInput — modify the player\'s input\nconst modifier = (text) => {\n return { text }\n}\nmodifier(text)\n',
})
navigate(`/scripts/${script.id}`)
}
const importScript = async () => {
try {
const bundle = await pickJSONFile()
const script = await api.importScript(bundle)
navigate(`/scripts/${script.id}`)
} catch (err) {
alert(err.message)
}
}
return (
<div className="page">
<div className="page-header">
<h1>Scripts</h1>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={importScript}>Import</button>
<button className="primary" onClick={createScript}>+ New Script</button>
</div>
</div>
{scripts === null ? null : scripts.length === 0 ? (
<div className="empty">
No scripts yet. Scripts are AI Dungeon-compatible JavaScript modifiers
(onInput / onModelContext / onOutput) you can attach to scenarios.
</div>
) : (
<div className="card-grid">
{scripts.map((s) => (
<div key={s.id} className="card" onClick={() => navigate(`/scripts/${s.id}`)}>
<h3>{s.name}</h3>
<p>{s.description || 'No description'}</p>
<div className="meta">
{['library', 'input', 'context', 'output']
.filter((slot) => s[`${slot}_js`].trim())
.join(' · ') || 'empty'}
</div>
</div>
))}
</div>
)}
</div>
)
}
+187
View File
@@ -0,0 +1,187 @@
import { useEffect, useState } from 'react'
import { api } from '../api'
function DebugLog() {
const [entries, setEntries] = useState(null)
const [openId, setOpenId] = useState(null)
const load = () => api.getDebugRequests().then(setEntries).catch(() => setEntries([]))
useEffect(() => { load() }, [])
if (!entries) return null
return (
<div className="debug-log">
<div className="page-header" style={{ marginTop: 28 }}>
<h3 style={{ margin: 0 }}>Recent AI requests</h3>
<button onClick={load}>Refresh</button>
</div>
{entries.length === 0 && (
<div className="empty" style={{ padding: '16px 0' }}>
No requests yet this session. Play a turn and refresh.
</div>
)}
{entries.map((e) => (
<div key={e.id} className="debug-entry">
<div className="debug-entry-head" onClick={() => setOpenId(openId === e.id ? null : e.id)}>
<span className={`debug-status ${e.status}`}>{e.status}</span>
<span>{e.model}</span>
<span className="dim">{new Date(e.time).toLocaleTimeString()}</span>
<span className="dim" style={{ marginLeft: 'auto' }}>{openId === e.id ? '▾' : '▸'}</span>
</div>
{openId === e.id && (
<div className="debug-entry-body">
<div className="dim">{e.url}</div>
{e.error && <div className="test-error">{e.error}</div>}
<div className="ctx-header" style={{ padding: '6px 0 2px' }}><span>Request</span></div>
<pre>{JSON.stringify(e.request, null, 2)}</pre>
<div className="ctx-header" style={{ padding: '6px 0 2px' }}><span>Response</span></div>
<pre>{e.response || '(empty)'}</pre>
</div>
)}
</div>
))}
</div>
)
}
export default function Settings() {
const [settings, setSettings] = useState(null)
const [testResult, setTestResult] = useState(null)
const [saved, setSaved] = useState('')
useEffect(() => {
api.getSettings().then(setSettings)
}, [])
const setField = (field, value) => setSettings({ ...settings, [field]: value })
const save = async () => {
await api.updateSettings(settings)
setSaved('Settings saved')
setTimeout(() => setSaved(''), 2000)
}
const test = async () => {
setTestResult({ pending: true })
await api.updateSettings(settings)
const result = await api.testConnection()
setTestResult(result)
}
if (!settings) return null
return (
<div className="page" style={{ maxWidth: 640 }}>
<div className="page-header">
<h1>Settings</h1>
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{saved}</span>
</div>
<label className="field">
<span className="label">Endpoint URL (OpenAI-compatible)</span>
<input type="text" value={settings.endpoint_url}
placeholder="http://localhost:11434/v1"
onChange={(e) => setField('endpoint_url', e.target.value)} />
</label>
<label className="field">
<span className="label">API Key</span>
<input type="password" value={settings.api_key}
placeholder="Leave empty for local endpoints"
onChange={(e) => setField('api_key', e.target.value)} />
</label>
<label className="field">
<span className="label">Model</span>
<input type="text" value={settings.model} placeholder="e.g. llama3.1"
onChange={(e) => setField('model', e.target.value)} />
</label>
<div style={{ display: 'flex', gap: 14 }}>
<label className="field" style={{ flex: 1 }}>
<span className="label">Temperature</span>
<input type="number" step="0.1" min="0" max="2" value={settings.temperature}
onChange={(e) => setField('temperature', Number(e.target.value))} />
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Max output tokens</span>
<input type="number" value={settings.max_output_tokens}
onChange={(e) => setField('max_output_tokens', Number(e.target.value))} />
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Context budget (tokens)</span>
<input type="number" value={settings.context_token_budget}
onChange={(e) => setField('context_token_budget', Number(e.target.value))} />
</label>
</div>
<label className="field">
<span className="label">Reasoning budget (tokens)</span>
<input type="number" min="0" value={settings.reasoning_max_tokens}
onChange={(e) => setField('reasoning_max_tokens', Number(e.target.value))} />
<span className="label" style={{ marginTop: 4 }}>
For reasoning models: separate thinking budget on top of max output tokens,
and thinking is shown collapsed above each response. 0 = off (nothing extra
is sent — keep 0 for endpoints/models without reasoning support).
</span>
</label>
<label className="field">
<span className="label">API mode</span>
<select value={settings.api_mode} onChange={(e) => setField('api_mode', e.target.value)}>
<option value="chat">Chat (/v1/chat/completions)</option>
<option value="completion">Completion (/v1/completions)</option>
</select>
</label>
<div className="page-header" style={{ marginTop: 20 }}>
<h3 style={{ margin: 0 }}>Memory system</h3>
</div>
<div style={{ display: 'flex', gap: 14 }}>
<label className="field" style={{ flex: 1 }}>
<span className="label">Summarization model</span>
<input type="text" value={settings.summary_model}
placeholder="Empty = use main model"
onChange={(e) => setField('summary_model', e.target.value)} />
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Embedding model</span>
<input type="text" value={settings.embedding_model}
placeholder="e.g. nomic-embed-text (empty = memory bank off)"
onChange={(e) => setField('embedding_model', e.target.value)} />
</label>
</div>
<div style={{ display: 'flex', gap: 14 }}>
<label className="field" style={{ flex: 1 }}>
<span className="label">Memory bank capacity</span>
<input type="number" min="1" value={settings.memory_bank_capacity}
onChange={(e) => setField('memory_bank_capacity', Number(e.target.value))} />
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Memories per turn (top-K)</span>
<input type="number" min="1" value={settings.memory_top_k}
onChange={(e) => setField('memory_top_k', Number(e.target.value))} />
</label>
</div>
<label className="field">
<span className="label">Narrator system prompt</span>
<textarea rows={4} value={settings.narrator_prompt}
onChange={(e) => setField('narrator_prompt', e.target.value)} />
</label>
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>
<button className="primary" onClick={save}>Save</button>
<button onClick={test}>Test connection</button>
</div>
{testResult && (
<div style={{ marginTop: 16, color: testResult.ok ? 'var(--accent)' : 'var(--danger)' }}>
{testResult.pending ? 'Testing…'
: testResult.ok
? `Connected. Models: ${testResult.models?.slice(0, 8).join(', ') || '(none listed)'}`
: testResult.detail}
</div>
)}
<DebugLog />
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000',
},
},
})
+74
View File
@@ -0,0 +1,74 @@
# AI D&D — Local AI Dungeon Clone: Plan Overview
A locally hosted web app replicating AI Dungeon's core experience: scenarios, adventures,
AI-driven storytelling, AI Dungeon-style memory/context management, JavaScript scripting
(compatible with real AI Dungeon scripts), and full transparency into what is sent to the AI.
## Confirmed decisions
| Area | Decision |
|---|---|
| Backend | Python — FastAPI + SQLAlchemy + SQLite (single-user, local) |
| Frontend | React SPA (Vite), dark AI Dungeon-like theme |
| AI provider | Provider-agnostic adapter layer; first adapter: **OpenAI-compatible** (`/v1/chat/completions`) — covers Ollama, LM Studio, OpenAI, OpenRouter, vLLM, Groq. Endpoint URL, API key, model name all configurable at runtime. |
| Scripting | **JavaScript, AI Dungeon-compatible** (`onInput` / `onModelContext` / `onOutput` modifiers, shared `state`, `worldEntries` API) via an embedded JS engine (quickjs / py-mini-racer). Real AI Dungeon scripts should import and run. |
| Import/export | AI Dungeon-compatible formats for scripts and scenarios; JSON export/import for everything. |
## Architecture at a glance
```
frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
├─ routers/ (scenarios, adventures, actions, scripts, settings, insights)
├─ models/ (SQLAlchemy: Scenario, Adventure, Action, StoryCard, Script, Settings)
├─ context/ (prompt assembly: memory, author's note, world info, history budget)
├─ scripting/ (JS sandbox, AI Dungeon API surface, per-adventure state)
├─ providers/ (base adapter + openai_compatible.py; streaming)
└─ data.db (SQLite)
```
## Core domain model
- **Scenario** — template: title, description, opening prompt (with `${placeholders}`), memory,
author's note, story cards (world info), attached scripts, tags.
- **Adventure** — a playthrough created from a scenario (or blank). Owns its own copy of memory,
author's note, story cards, script state, and the action list.
- **Action** — one entry in the story: type (`do` / `say` / `story` / `continue` / AI output),
text, timestamp, plus the **context snapshot** (exact prompt sent to the AI) for Insights.
- **Story Card / World Info** — keys (comma-separated keywords), entry text, optional type/notes.
Injected into context only when a key matches recent story text.
- **Script** — JS source per hook (input / context / output modifier), attachable to scenarios;
copied into adventures with persistent `state`.
## The turn pipeline (heart of the app)
```
player input
→ onInput script modifier
→ store player action
→ assemble context: [AI instructions] + [plot essentials] + [story summary]
+ [triggered story cards ("World Lore:")] + [story history, token-budgeted]
+ [author's note inserted N lines from the end] + [player action]
→ onModelContext script modifier
→ snapshot context (Insights)
→ provider adapter → AI (streamed)
→ onOutput script modifier
→ store AI action → render
```
## Phases
1. **[Phase 1 — Foundation](01-phase-foundation.md)**: repo scaffold, FastAPI + SQLite models,
React shell, scenario/adventure CRUD, settings (endpoint config).
2. **[Phase 2 — Play loop + AI](02-phase-play-loop.md)**: provider adapter with streaming,
Do/Say/Story/Continue, Retry/Undo/Edit, the adventure play screen.
3. **[Phase 3 — Context engine + Insights](03-phase-context-insights.md)**: memory, author's note,
story cards with keyword triggering, token budgeting, per-turn prompt snapshots + Insights UI.
4. **[Phase 4 — Scripting](04-phase-scripting.md)**: embedded JS sandbox, AI Dungeon scripting API,
script editor, script + scenario import/export (AI Dungeon-compatible).
5. **[Phase 5 — Polish](05-phase-polish.md)**: AI Dungeon-like theming pass, placeholders on
scenario start, adventure export/import, quality-of-life and hardening.
6. **[Phase 6 — Auto Summarization + Memory Bank](06-phase-memory-bank.md)** *(optional)*:
modern AI Dungeon memory system — AI-generated memories every 6 actions, Story Summary every
15, embedding-based retrieval of relevant memories into context.
Each phase ends with the app runnable and testable end-to-end.
+38
View File
@@ -0,0 +1,38 @@
# Phase 1 — Foundation
**Goal:** runnable skeleton — backend serving a database-backed API, frontend shell with
navigation, scenario & adventure CRUD, and a settings page for the AI endpoint. No AI calls yet.
## Backend
- [x] Project scaffold: `backend/` with FastAPI app, uvicorn entrypoint, `requirements.txt`
(fastapi, uvicorn, sqlalchemy, pydantic, httpx, tiktoken; quickjs deferred to phase 4).
- [x] SQLite via SQLAlchemy; auto-create `data.db` on first run.
- [x] Models:
- `Scenario`: id, title, description, prompt, memory, authors_note, tags, created/updated.
- `StoryCard`: id, owner (scenario or adventure), keys, entry, type, title, description.
- `Adventure`: id, scenario_id (nullable), title, memory, authors_note, script_state (JSON),
created/updated.
- `Action`: id, adventure_id, index, type (`do|say|story|continue|ai|start`), text,
context_snapshot (JSON, nullable), created.
- `Script`: id, name, description, input_js, context_js, output_js, library_js.
- `Settings`: single row — endpoint_url, api_key, model, temperature, max_output_tokens,
context_token_budget, api_mode (`chat|completion`).
- [x] Routers: CRUD for scenarios, adventures (+ create-from-scenario copying memory/AN/cards),
story cards, settings. Consistent JSON errors.
- [x] CORS for the Vite dev server; production mode serves built frontend as static files.
## Frontend
- [x] Vite + React scaffold in `frontend/`; router with pages: Home (adventure list),
Scenarios (list + editor), Play (placeholder), Settings.
- [x] Dark base theme (AI Dungeon-like: near-black background, serif story font, gold/teal accent).
- [x] Scenario editor: title, description, prompt, memory, author's note, story card list editor.
- [x] Settings page: endpoint URL, API key, model name, sampling params; "Test connection" button
(backend proxies a trivial request — wired for real in Phase 2, stub now).
- [x] "New adventure" flow: pick scenario (or blank) → creates adventure → navigates to Play page.
## Exit criteria
Run `uvicorn` + `npm run dev`, create/edit/delete scenarios with story cards, start an adventure
from one, see it listed on Home, and save endpoint settings — all persisted across restarts.
+45
View File
@@ -0,0 +1,45 @@
# Phase 2 — Play loop + AI provider
**Goal:** the core game is playable. Player acts, AI continues the story, streamed live.
## Provider adapter layer
- [x] `providers/base.py`: abstract `Provider` — `generate(prompt_parts, params) -> async stream of text`.
Takes an assembled context object (system text + story text), so providers decide how to
map it to their wire format.
- [x] `providers/openai_compatible.py`:
- Chat mode: system message carries instructions/memory; story history flows in as
user/assistant text continuation framing suited to a chat endpoint. A configurable
"narrator" system prompt frames the AI as a second-person storyteller continuing the text.
- Streaming via SSE from the endpoint, re-streamed to the browser.
- Optional raw completion mode (`/v1/completions`) for pure-continuation models.
- [x] Errors surfaced cleanly (bad key, connection refused, model not found) with retry affordance.
- [x] "Test connection" on Settings now real.
## Turn engine (`POST /adventures/{id}/actions`)
- [x] Input formatting per AI Dungeon conventions:
- **Do** → `> You <text>` (normalized to second person, stripped punctuation as needed)
- **Say** → `> You say "<text>"`
- **Story** → raw text appended
- **Continue** → no player text; AI just continues
- [x] Simple context for this phase: opening prompt + full history, truncated from the top to the
token budget (tiktoken count). Real context engine lands in Phase 3.
- [x] Response streamed to the client via SSE; final text stored as an `ai` action.
- [x] **Retry**: delete last AI action, regenerate with same input.
- [x] **Undo/Erase**: delete last action pair (player + AI) or single action.
- [x] **Edit**: PATCH any action's text in place.
## Play UI
- [x] Story view: continuous prose (not chat bubbles), player actions styled distinctly
(`>` prefix, accent color), auto-scroll, streaming text renders token-by-token.
- [x] Input bar with mode selector (Do / Say / Story) + Continue button; Enter to send.
- [x] Per-turn controls: Retry, Undo, Edit (inline contenteditable or textarea swap).
- [x] Loading/streaming state, error toast with retry.
## Exit criteria
Point Settings at any OpenAI-compatible endpoint (e.g. Ollama or LM Studio locally), start an
adventure, and play a multi-turn story with all four input modes plus retry/undo/edit, with
streaming output.
+62
View File
@@ -0,0 +1,62 @@
# Phase 3 — Context engine + Insights
**Goal:** AI Dungeon-grade context management, and full visibility into every prompt.
## Context assembly (`context/builder.py`)
Assembles the prompt each turn from AI Dungeon's **plot components**
(per help.aidungeon.com/faq/the-memory-system):
```
[AI Instructions] ← behavioral guidance for the model (always included)
[Plot Essentials] ← key facts for constant recall — the classic "Memory" (always)
[Story Summary] ← running summary slot; manual in this phase, auto in Phase 6
[Triggered Story Cards] ← "World Lore: <entry>" for each triggered card (conditional)
[Story history] ← as many recent actions as fit the token budget
[Author's Note] ← injected N lines (default 3) before the end of history
[Latest player action] ← (+ script frontMemory right after it, Phase 4)
```
- [x] **AI Instructions / Plot Essentials / Story Summary / Author's Note**: adventure-level
free-text fields, always included, editable mid-adventure from the side panel.
- [x] **Story cards** — five fields per official docs: **Type** (organizational, not sent to AI),
**Name** (not sent to AI), **Entry** (sent when triggered), **Triggers**, **Notes** (not sent).
- Triggers: comma-separated words/phrases; **case-insensitive but space-sensitive**;
**partial-word matching** (`boat` triggers on `boats`); matched against both player input
and AI output in the recent-story window.
- Not instant: a card triggered mid-response only enters context on the *next* turn; once
triggered, stays active while the triggering text remains in the context window.
- Triggered entries injected once each, prefixed `World Lore:`; story cards are the **first
component dropped** when context is full.
- Editable per-adventure (copied from scenario at creation). Soft-cap sanity limit (AI Dungeon
allows 5,000/adventure).
- [x] **Author's Note**: inserted near the end (strongest steering position), formatted
`[Author's note: <text>]`.
- [x] **Token budgeting** with tiktoken: always-included components (AI Instructions, Plot
Essentials, Story Summary, Author's Note) reserved first; story cards get a capped share
and are dropped first when over budget; remainder goes to story history (newest first).
Budget = `context_token_budget` setting.
- [x] Slots for script-provided memory overrides (Phase 4): `state.memory.context` (prepended),
`state.memory.authorsNote` (replaces/augments author's note), `state.memory.frontMemory`
(inserted immediately after the latest player action).
- [x] Builder returns a structured `ContextReport`: ordered sections, each with source label,
text, token count; plus totals and a list of triggered cards (and which keyword fired).
## Insights
- [x] Every AI turn stores its `ContextReport` on the `Action` row (`context_snapshot`).
- [x] `GET /adventures/{id}/actions/{id}/context` returns it.
- [x] **Insights panel** in Play UI (drawer/tab):
- Exact final prompt text as sent, sectioned and color-coded (memory / world info / history /
author's note / input), with per-section token counts and total vs budget.
- Which story cards triggered and on which keyword; which history got cut off.
- Viewable for the *upcoming* turn (dry-run endpoint: "what would be sent now") and for any
past AI action.
- [x] Adventure side panel: edit memory, author's note, story cards mid-game (AI Dungeon's
right-hand panel equivalent).
## Exit criteria
Create a card with key `dragon`; mention a dragon in play and see the card enter the context in
the Insights panel (and influence the AI); verify memory and author's note appear in the snapshot
in the right positions; long adventures visibly trim oldest history within budget.
+84
View File
@@ -0,0 +1,84 @@
# Phase 4 — Scripting (AI Dungeon-compatible JavaScript)
**Goal:** real AI Dungeon scripts import and run: the three modifier hooks, persistent `state`,
and the scripting API surface.
## JS runtime
- [x] Embed a JS engine in Python: **quickjs** (preferred; check Windows wheel availability at
implementation time; fallback: py-mini-racer, or Node subprocess as last resort).
- [x] Sandbox matching AI Dungeon's documented limits: each hook runs **isolated**, **16 MB
memory cap**, **2-second timeout**; no filesystem/network/process access; script errors
captured and surfaced in the UI, never crash a turn.
## AI Dungeon scripting model (compatibility target)
*(Per official docs: help.aidungeon.com/faq/how-do-i-write-scripts-and-use-scripting)*
Three lifecycle hooks — `onInput`, `onModelContext`, `onOutput`. Each script defines a modifier
and **must call it as its last line**:
```javascript
const modifier = (text) => {
// script logic
return { text, stop }
}
modifier(text)
```
- **onInput** — modifies player input before context construction.
- **onModelContext** — modifies the assembled text sent to the model.
- **onOutput** — modifies the model output before it is shown/stored.
- **Shared Library** — code prepended to all three slots.
Return contract:
- [x] `{ text, stop }`; `stop: true` from onInput prevents the AI call.
- [x] Empty-string `text` from onInput/onOutput → user-facing error (replicate this behavior).
Globals provided (exact names from docs):
- [x] `text` — hook input (player input / context / AI response respectively).
- [x] `state` — persisted per adventure across turns (`Adventure.script_state`); includes
`state.memory`, `state.message` (shown as a UI notice), `state.placeholders`.
- [x] `state.memory` slots: `context` (prepended to context), `authorsNote` (near end, before
latest response), `frontMemory` (inserted right after the player's input).
- [x] `history` — array of recent actions: `{ text, rawText, type }`.
- [x] `storyCards` — array of `{ id, keys, entry, type }`, backed by the adventure's story cards.
- [x] Story card functions: `addStoryCard(keys, entry, type)` → index (or `false` on duplicate),
`updateStoryCard(index, keys, entry, type)` and `removeStoryCard(index)` → throw if absent.
- [x] Legacy aliases for older scripts: `worldInfo` / `worldEntries`, `addWorldEntry`,
`updateWorldEntry`, `removeWorldEntry` mapped onto the storyCards implementation.
- [x] `info` — `{ actionCount, characterNames, memoryLength, maxChars }`.
- [x] `log(message)` / `console.log` — captured per turn, shown in a script log panel.
## Pipeline integration
```
player input → INPUT modifier → format & store
context build → CONTEXT modifier → (snapshot includes pre- and post-script versions in Insights)
AI response → OUTPUT modifier → store & render
```
- [x] Insights (Phase 3) extended: show context before vs after the context modifier (diff view),
and script log output per turn.
## Script management UI
- [x] Scripts page: create/edit scripts with a code editor (CodeMirror), one tab per slot
(Library / Input / Context / Output), description field.
- [x] Attach scripts to scenarios; adventures inherit at creation. Enable/disable per adventure.
- [x] Test-run a script against sample text without an AI call.
## Import / Export
- [x] **Scripts**: export/import as JSON bundle `{ name, library, input, context, output }` and
as raw `.js` files per slot (matching how AI Dungeon scripts circulate — paste or file).
- [x] **Scenarios**: export/import JSON including prompt, memory, author's note, story cards,
and attached scripts. Accept AI Dungeon scenario export JSON where format is known;
map fields best-effort and report anything unmapped.
## Exit criteria
Paste a real AI Dungeon script (e.g. a simple input modifier + state counter + world entry
manipulation) and it runs unmodified across turns; state persists; export a scenario with scripts,
re-import it into a fresh database, and play it.
+38
View File
@@ -0,0 +1,38 @@
# Phase 5 — Polish & quality of life
**Goal:** the app feels like AI Dungeon — cohesive dark UI, smooth flows, safe data handling.
## UI/UX pass
- [x] Theming: refined dark palette, serif story typography, subtle textures/gradients à la
AI Dungeon; consistent buttons, panels, modals; responsive layout.
- [x] Home: adventure cards with scenario name, last-played time, action count; search/filter;
scenario gallery with tags.
- [x] Play screen: collapsible right side panel (Memory / Cards / Scripts / Insights tabs),
keyboard shortcuts (Enter send, Ctrl+Z undo, Ctrl+R retry), smooth streaming autoscroll
that pauses when the user scrolls up.
- [x] Scenario **placeholders**: `${Character name}` style variables in prompt/memory prompt the
player for values when starting an adventure (AI Dungeon behavior).
## Data & robustness
- [x] Adventure export/import (full JSON: actions, memory, cards, script state) — backup/share.
- [x] Delete confirmations (trash/soft-delete skipped — plain confirm dialogs).
- [x] SQLite migrations story (versioned schema bootstrap via PRAGMA user_version —
`backend/app/migrations.py`).
- [x] Request logging + a debug page tailing recent provider requests/responses (bodies redacted
of API key) — "Recent AI requests" on the Settings page.
- [x] Graceful handling: provider timeout/cancel (stop generation button), concurrent turn lock
per adventure.
## Nice-to-haves (only if time/interest)
- [ ] Multiple provider profiles with quick switching (e.g. local Ollama vs OpenRouter).
- [ ] Per-scenario generation params overriding global settings.
- [ ] Retry with "give me something different" (temperature bump / anti-repeat nudge).
- [ ] Basic light theme toggle.
## Exit criteria
A friend could sit down at `localhost`, start a scenario with placeholders, play comfortably,
peek at Insights, and you can back up / restore everything via export files.
+47
View File
@@ -0,0 +1,47 @@
# Phase 6 — Auto Summarization + Memory Bank (optional)
**Goal:** replicate modern AI Dungeon's Memory System (per
help.aidungeon.com/faq/the-memory-system): AI-generated memories, a running Story Summary, and
embedding-based retrieval. This phase makes extra AI calls (summarization + embeddings), so it is
opt-in per adventure and gated on the endpoint supporting it.
## Auto Summarization
- [x] **Memories**: every 6 actions (starting at action 12), summarize that block of
player actions + AI responses into a short "memory" via a background AI call
(same provider, cheap/configurable model override).
- [x] **Story Summary**: every 15 actions, update the Story Summary plot component — a running
overview of the plot — folding in recent memories; compress it when it grows too long.
- [x] Story Summary stays **manually editable**; user edits inform future updates
(they are the base text for the next summarization pass) but are never overwritten silently.
- [x] Summarization failures are non-fatal: log, retry next interval.
(Failed calls appear on the debug page; cursors only advance on success, so the
next turn retries. Implementation: `backend/app/memorybank.py`.)
## Memory Bank
- [x] Store each memory with an **embedding vector** (OpenAI-compatible `/v1/embeddings`;
embedding model configurable in Settings; feature disabled if unavailable).
- [x] Each turn, embed the recent story text and rank memories by cosine similarity;
inject the top-K "Used Memories" into context as their own component
(between Story Summary and story cards in the layout). Pinned memories are always
included; top-K is a setting (default 5).
- [x] Configurable bank capacity (AI Dungeon tiers: 25–400; ours: a setting, default 200);
when full, evict least-recently-used/least-retrieved memories ("Forgotten Memories").
- [x] SQLite storage for vectors (JSON blob + in-memory cosine ranking — pure Python,
no numpy needed at this scale).
## UI
- [x] Memory Bank panel: list memories (used / idle / forgotten), edit or delete, pin favorites,
see which memories were retrieved for a given turn (🔍 on an AI action → Insights snapshot).
- [x] Insights integration: retrieved memories shown as a context section with similarity scores.
- [x] Adventure settings: toggle auto-summarization / memory bank (per adventure, in the Memory
panel); summary + embedding models are chosen globally in Settings (deliberate
simplification — one endpoint config for the whole app).
## Exit criteria
Play a 40+ action adventure: memories appear every 6 actions, the Story Summary updates every 15,
an early-game fact that scrolled out of the raw history gets retrieved via the Memory Bank when it
becomes relevant again, and Insights shows exactly which memories were injected and why.
+9
View File
@@ -0,0 +1,9 @@
# Starts backend (FastAPI, :8000) and frontend dev server (Vite, :5173) in separate windows.
$root = $PSScriptRoot
Start-Process powershell -ArgumentList '-NoExit', '-Command', "Set-Location '$root\backend'; & '.\.venv\Scripts\uvicorn.exe' app.main:app --port 8000 --reload"
Start-Process powershell -ArgumentList '-NoExit', '-Command', "Set-Location '$root\frontend'; npm run dev"
Write-Host 'Backend: http://localhost:8000 (API docs: http://localhost:8000/docs)'
Write-Host 'Frontend: http://localhost:5173'