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:
+18
@@ -0,0 +1,18 @@
|
||||
# Secrets
|
||||
*.env
|
||||
backend/openrouter_key.env
|
||||
|
||||
# Python
|
||||
backend/.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Database
|
||||
*.db
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Misc
|
||||
.claude/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from .builder import build_context, count_tokens, truncate_to_last_tokens
|
||||
|
||||
__all__ = ["build_context", "count_tokens", "truncate_to_last_tokens"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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}"))
|
||||
@@ -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)
|
||||
@@ -0,0 +1,4 @@
|
||||
from .base import PromptParts, Provider, ProviderError
|
||||
from .openai_compatible import OpenAICompatibleProvider
|
||||
|
||||
__all__ = ["PromptParts", "Provider", "ProviderError", "OpenAICompatibleProvider"]
|
||||
@@ -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."""
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
from .engine import HookResult, run_hook
|
||||
from .pipeline import ScriptPipeline
|
||||
|
||||
__all__ = ["HookResult", "ScriptPipeline", "run_hook"]
|
||||
@@ -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 [],
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=Crimson+Pro:ital,wght@0,400;0,500;0,600;1,400&family=Inter:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>AI D&D</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1643
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@uiw/react-codemirror": "^4.25.10",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,24 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<nav className="topnav">
|
||||
<span className="brand">⚔ AI D&D</span>
|
||||
<NavLink to="/" end className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
||||
Adventures
|
||||
</NavLink>
|
||||
<NavLink to="/scenarios" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
||||
Scenarios
|
||||
</NavLink>
|
||||
<NavLink to="/scripts" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
||||
Scripts
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
|
||||
Settings
|
||||
</NavLink>
|
||||
</nav>
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
async function request(path, options = {}) {
|
||||
const resp = await fetch(`/api${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
if (!resp.ok) {
|
||||
let detail = resp.statusText
|
||||
try {
|
||||
const body = await resp.json()
|
||||
detail = body.detail || detail
|
||||
} catch { /* non-JSON error body */ }
|
||||
throw new Error(detail)
|
||||
}
|
||||
if (resp.status === 204) return null
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
// POSTs to an SSE endpoint and dispatches events: {type: 'player'|'chunk'|'done'|'error', ...}
|
||||
async function streamSSE(path, payload, onEvent, signal) {
|
||||
const resp = await fetch(`/api${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
})
|
||||
if (!resp.ok) {
|
||||
let detail = resp.statusText
|
||||
try { detail = (await resp.json()).detail || detail } catch { /* non-JSON */ }
|
||||
throw new Error(detail)
|
||||
}
|
||||
const reader = resp.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const events = buffer.split('\n\n')
|
||||
buffer = events.pop() // keep incomplete tail
|
||||
for (const block of events) {
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.startsWith('data:')) onEvent(JSON.parse(line.slice(5)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Scenarios
|
||||
listScenarios: () => request('/scenarios'),
|
||||
getScenario: (id) => request(`/scenarios/${id}`),
|
||||
createScenario: (data) => request('/scenarios', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateScenario: (id, data) => request(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteScenario: (id) => request(`/scenarios/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Adventures
|
||||
listAdventures: () => request('/adventures'),
|
||||
getAdventure: (id) => request(`/adventures/${id}`),
|
||||
createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }),
|
||||
updateAction: (advId, actionId, text) =>
|
||||
request(`/adventures/${advId}/actions/${actionId}`, { method: 'PATCH', body: JSON.stringify({ text }) }),
|
||||
deleteAction: (advId, actionId) =>
|
||||
request(`/adventures/${advId}/actions/${actionId}`, { method: 'DELETE' }),
|
||||
|
||||
sendAction: (advId, payload, handlers, signal) =>
|
||||
streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal),
|
||||
retry: (advId, handlers, signal) => streamSSE(`/adventures/${advId}/retry`, {}, handlers, signal),
|
||||
exportAdventure: (id) => request(`/adventures/${id}/export`),
|
||||
importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }),
|
||||
undo: (advId) => request(`/adventures/${advId}/undo`, { method: 'POST' }),
|
||||
getAdventureContext: (advId) => request(`/adventures/${advId}/context`),
|
||||
getActionContext: (advId, actionId) => request(`/adventures/${advId}/actions/${actionId}/context`),
|
||||
|
||||
// Memory bank
|
||||
listMemories: (advId) => request(`/adventures/${advId}/memories`),
|
||||
createMemory: (advId, text) =>
|
||||
request(`/adventures/${advId}/memories`, { method: 'POST', body: JSON.stringify({ text }) }),
|
||||
updateMemory: (advId, memoryId, data) =>
|
||||
request(`/adventures/${advId}/memories/${memoryId}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteMemory: (advId, memoryId) =>
|
||||
request(`/adventures/${advId}/memories/${memoryId}`, { method: 'DELETE' }),
|
||||
|
||||
listAdventureScripts: (advId) => request(`/adventures/${advId}/scripts`),
|
||||
updateAdventureScript: (advId, scriptId, data) =>
|
||||
request(`/adventures/${advId}/scripts/${scriptId}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
|
||||
// Scripts
|
||||
listScripts: () => request('/scripts'),
|
||||
getScript: (id) => request(`/scripts/${id}`),
|
||||
createScript: (data) => request('/scripts', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateScript: (id, data) => request(`/scripts/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteScript: (id) => request(`/scripts/${id}`, { method: 'DELETE' }),
|
||||
testScript: (id, data) => request(`/scripts/${id}/test`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
exportScript: (id) => request(`/scripts/${id}/export`),
|
||||
importScript: (bundle) => request('/scripts/import', { method: 'POST', body: JSON.stringify(bundle) }),
|
||||
|
||||
// Scenario import/export
|
||||
exportScenario: (id) => request(`/scenarios/${id}/export`),
|
||||
importScenario: (bundle) => request('/scenarios/import', { method: 'POST', body: JSON.stringify(bundle) }),
|
||||
|
||||
// Story cards
|
||||
createStoryCard: (data) => request('/story-cards', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateStoryCard: (id, data) => request(`/story-cards/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteStoryCard: (id) => request(`/story-cards/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Debug
|
||||
getDebugRequests: () => request('/debug/requests'),
|
||||
|
||||
// Settings
|
||||
getSettings: () => request('/settings'),
|
||||
updateSettings: (data) => request('/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
testConnection: () => request('/settings/test', { method: 'POST' }),
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export function downloadJSON(obj, filename) {
|
||||
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function pickJSONFile() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.json,application/json'
|
||||
input.onchange = () => {
|
||||
const file = input.files[0]
|
||||
if (!file) return reject(new Error('No file selected'))
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try { resolve(JSON.parse(reader.result)) }
|
||||
catch { reject(new Error('Not valid JSON')) }
|
||||
}
|
||||
reader.onerror = () => reject(new Error('Could not read file'))
|
||||
reader.readAsText(file)
|
||||
}
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
// Unique ${Placeholder} names, in order of first appearance, across the given texts.
|
||||
export function extractPlaceholders(...texts) {
|
||||
const names = []
|
||||
for (const text of texts) {
|
||||
for (const match of (text || '').matchAll(/\$\{([^}]+)\}/g)) {
|
||||
const name = match[1].trim()
|
||||
if (name && !names.includes(name)) names.push(name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
export function PlaceholderModal({ title, names, onSubmit, onCancel }) {
|
||||
const [values, setValues] = useState(Object.fromEntries(names.map((n) => [n, ''])))
|
||||
|
||||
const submit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h2>{title}</h2>
|
||||
<p className="modal-hint">This scenario asks a few questions before you begin.</p>
|
||||
{names.map((name, i) => (
|
||||
<label key={name} className="field">
|
||||
<span className="label">{name}</span>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus={i === 0}
|
||||
value={values[name]}
|
||||
onChange={(e) => setValues({ ...values, [name]: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<div className="modal-buttons">
|
||||
<button type="button" onClick={onCancel}>Cancel</button>
|
||||
<button type="submit" className="primary">Begin Adventure</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Field({ label, value, onChange, textarea, rows, placeholder }) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="label">{label}</span>
|
||||
{textarea ? (
|
||||
<textarea rows={rows || 3} value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
|
||||
) : (
|
||||
<input type="text" value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function StoryCardRow({ card, onChange, onDelete }) {
|
||||
return (
|
||||
<div className="storycard">
|
||||
<div className="row">
|
||||
<input type="text" placeholder="Name (not sent to AI)" value={card.name}
|
||||
onChange={(e) => onChange({ ...card, name: e.target.value })} />
|
||||
<input type="text" placeholder="Type (e.g. Character)" value={card.type}
|
||||
onChange={(e) => onChange({ ...card, type: e.target.value })} />
|
||||
</div>
|
||||
<div className="row">
|
||||
<input type="text" placeholder="Triggers, comma-separated" value={card.keys}
|
||||
onChange={(e) => onChange({ ...card, keys: e.target.value })} />
|
||||
</div>
|
||||
<textarea rows={2} placeholder="Entry — sent to the AI when a trigger matches" value={card.entry}
|
||||
onChange={(e) => onChange({ ...card, entry: e.target.value })} />
|
||||
<div style={{ textAlign: 'right', marginTop: 6 }}>
|
||||
<button className="danger" style={{ padding: '3px 10px', fontSize: '0.78rem' }} onClick={onDelete}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--bg-panel: #131320;
|
||||
--bg-panel-glass: rgba(19, 19, 32, 0.82);
|
||||
--bg-input: #1a1a2a;
|
||||
--border: #2b2b3d;
|
||||
--border-bright: #3d3d55;
|
||||
--text: #e2ddd0;
|
||||
--text-dim: #918c7d;
|
||||
--accent: #d4a94e;
|
||||
--accent-bright: #e8c476;
|
||||
--accent-dim: #96773a;
|
||||
--accent-glow: rgba(212, 169, 78, 0.25);
|
||||
--danger: #d06565;
|
||||
--player: #9fc7d1;
|
||||
--font-display: 'Cinzel', Georgia, serif;
|
||||
--font-story: 'Crimson Pro', Georgia, 'Times New Roman', serif;
|
||||
--font-ui: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||
font-family: var(--font-ui);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(1200px 700px at 15% -10%, rgba(212, 169, 78, 0.06), transparent 60%),
|
||||
radial-gradient(1000px 600px at 90% 110%, rgba(88, 76, 140, 0.08), transparent 55%),
|
||||
var(--bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
h1, h2, h3 { font-weight: 600; letter-spacing: 0.03em; }
|
||||
a { color: var(--accent); text-decoration: none; transition: color 0.15s; }
|
||||
a:hover { color: var(--accent-bright); }
|
||||
|
||||
::selection { background: var(--accent-dim); color: #0f0d08; }
|
||||
|
||||
/* ---------- Scrollbars ---------- */
|
||||
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-bright);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); }
|
||||
|
||||
/* ---------- Top navigation ---------- */
|
||||
|
||||
.topnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
padding: 0 28px;
|
||||
height: 56px;
|
||||
background: var(--bg-panel-glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.topnav .brand {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.14em;
|
||||
text-shadow: 0 0 18px var(--accent-glow);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.topnav a.navlink {
|
||||
color: var(--text-dim);
|
||||
padding: 17px 2px 15px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.2s;
|
||||
}
|
||||
.topnav a.navlink.active {
|
||||
color: var(--accent-bright);
|
||||
border-bottom-color: var(--accent);
|
||||
text-shadow: 0 0 14px var(--accent-glow);
|
||||
}
|
||||
.topnav a.navlink:hover { color: var(--text); }
|
||||
|
||||
.page {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px;
|
||||
animation: page-in 0.35s ease both;
|
||||
}
|
||||
@keyframes page-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* ---------- Forms ---------- */
|
||||
|
||||
label.field { display: block; margin-bottom: 14px; }
|
||||
label.field .label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
input[type="text"], input[type="password"], input[type="number"], textarea, select {
|
||||
width: 100%;
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-dim);
|
||||
box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.12);
|
||||
}
|
||||
input::placeholder, textarea::placeholder { color: #5c584d; }
|
||||
|
||||
button {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 7px 16px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, transform 0.1s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: var(--accent-dim);
|
||||
box-shadow: 0 0 12px rgba(212, 169, 78, 0.1);
|
||||
}
|
||||
button:active { transform: translateY(1px); }
|
||||
button.primary {
|
||||
background: linear-gradient(160deg, var(--accent-bright), var(--accent) 55%, #b78d3e);
|
||||
border-color: var(--accent);
|
||||
color: #191307;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
box-shadow: 0 2px 10px var(--accent-glow);
|
||||
}
|
||||
button.primary:hover {
|
||||
box-shadow: 0 2px 18px rgba(212, 169, 78, 0.45);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
button.danger { color: var(--danger); }
|
||||
button.danger:hover { border-color: var(--danger); box-shadow: 0 0 12px rgba(208, 101, 101, 0.15); }
|
||||
button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: none; }
|
||||
|
||||
/* ---------- Cards & lists ---------- */
|
||||
|
||||
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
|
||||
.card {
|
||||
position: relative;
|
||||
background: linear-gradient(170deg, #17172400, #17172400 60%, rgba(212, 169, 78, 0.03)), var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-dim), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--border-bright);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45), 0 0 20px rgba(212, 169, 78, 0.06);
|
||||
}
|
||||
.card:hover::before { opacity: 1; }
|
||||
.card h3 {
|
||||
margin: 0 0 6px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.02rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
.card p { margin: 0; color: var(--text-dim); font-size: 0.88rem; line-height: 1.5; }
|
||||
.card .meta { margin-top: 12px; font-size: 0.75rem; color: var(--text-dim); }
|
||||
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.55rem;
|
||||
color: var(--accent-bright);
|
||||
text-shadow: 0 0 24px var(--accent-glow);
|
||||
}
|
||||
|
||||
.empty { color: var(--text-dim); text-align: center; padding: 60px 0; font-style: italic; font-family: var(--font-story); font-size: 1.05rem; }
|
||||
|
||||
/* ---------- Story card editor ---------- */
|
||||
|
||||
.storycard {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent-dim);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.storycard:focus-within { border-color: var(--accent-dim); }
|
||||
.storycard .row { display: flex; gap: 10px; margin-bottom: 8px; }
|
||||
.storycard .row > * { flex: 1; }
|
||||
|
||||
/* ---------- Play page ---------- */
|
||||
|
||||
.play-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
|
||||
|
||||
.story {
|
||||
flex: 1;
|
||||
font-family: var(--font-story);
|
||||
font-size: 1.14rem;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.story .action {
|
||||
position: relative;
|
||||
margin-bottom: 15px;
|
||||
padding-right: 56px;
|
||||
animation: action-in 0.4s ease both;
|
||||
}
|
||||
@keyframes action-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.story .action.player {
|
||||
color: var(--player);
|
||||
border-left: 2px solid rgba(159, 199, 209, 0.35);
|
||||
padding-left: 14px;
|
||||
font-style: italic;
|
||||
}
|
||||
.story .action.streaming { color: var(--text); }
|
||||
.story .cursor {
|
||||
color: var(--accent-bright);
|
||||
text-shadow: 0 0 8px var(--accent-glow);
|
||||
animation: blink 1s steps(1) infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
/* Reasoning-model thinking, collapsed above the AI's response */
|
||||
.story .reasoning {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-dim);
|
||||
font-style: normal;
|
||||
}
|
||||
.story .reasoning summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.story .reasoning summary:hover { opacity: 1; }
|
||||
.story .reasoning .reasoning-text {
|
||||
margin-top: 6px;
|
||||
padding: 8px 12px;
|
||||
border-left: 2px solid var(--border);
|
||||
opacity: 0.85;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.story .action-tools {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.story .action:hover .action-tools { opacity: 1; pointer-events: auto; }
|
||||
.story .action-tools button {
|
||||
padding: 1px 8px;
|
||||
font-size: 0.72rem;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-dim);
|
||||
background: var(--bg-panel-glass);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.story .action-tools button:hover { color: var(--text); }
|
||||
|
||||
.action-edit { margin-bottom: 14px; }
|
||||
.action-edit textarea { min-height: 110px; font-family: inherit; font-size: inherit; }
|
||||
.action-edit .edit-buttons { display: flex; gap: 8px; margin-top: 6px; }
|
||||
|
||||
.play-controls {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, var(--bg) 75%, transparent);
|
||||
padding: 18px 0 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.turn-buttons { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||
.turn-buttons button { font-size: 0.78rem; padding: 4px 12px; color: var(--text-dim); }
|
||||
.turn-buttons button:hover:not(:disabled) { color: var(--text); }
|
||||
|
||||
.input-bar { display: flex; gap: 8px; align-items: stretch; }
|
||||
.input-bar input { flex: 1; }
|
||||
|
||||
.mode-select { display: flex; }
|
||||
.mode-select button {
|
||||
border-radius: 0;
|
||||
border-right-width: 0;
|
||||
padding: 7px 14px;
|
||||
color: var(--text-dim);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.mode-select button:first-child { border-radius: 8px 0 0 8px; }
|
||||
.mode-select button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; }
|
||||
.mode-select button.active {
|
||||
color: var(--accent-bright);
|
||||
background: var(--bg-panel);
|
||||
border-color: var(--accent-dim);
|
||||
border-right-width: 1px;
|
||||
box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08);
|
||||
}
|
||||
.mode-select button.active + button { border-left-width: 0; }
|
||||
|
||||
/* ---------- Side panel (Plot / Insights) ---------- */
|
||||
|
||||
.play-layout { flex: 1; display: flex; min-height: calc(100vh - 56px); }
|
||||
.play-layout .play-page { max-width: 960px; }
|
||||
.play-layout.with-panel .play-page { max-width: none; }
|
||||
|
||||
.panel-toggles { display: flex; gap: 8px; }
|
||||
.panel-toggles button.active {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--accent-dim);
|
||||
box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08);
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
width: 420px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
height: calc(100vh - 56px);
|
||||
position: sticky;
|
||||
top: 56px;
|
||||
animation: panel-in 0.25s ease both;
|
||||
}
|
||||
@keyframes panel-in {
|
||||
from { opacity: 0; transform: translateX(16px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.side-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.side-panel-header h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- Insights ---------- */
|
||||
|
||||
.insights-meta { margin-bottom: 16px; font-size: 0.85rem; }
|
||||
.insights-mode { color: var(--text-dim); margin-bottom: 8px; }
|
||||
.insights-mode button { padding: 2px 8px; font-size: 0.75rem; }
|
||||
.token-total { font-size: 1rem; color: var(--text); margin-bottom: 4px; }
|
||||
.token-total.over { color: var(--danger); }
|
||||
.token-bar { height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; margin-bottom: 8px; }
|
||||
.token-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-dim), var(--accent-bright));
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.insights-history { color: var(--text-dim); margin-bottom: 8px; }
|
||||
.insights-cards div { margin-bottom: 3px; }
|
||||
.insights-cards .dropped { color: var(--danger); }
|
||||
|
||||
.ctx-section { margin-bottom: 12px; border-left: 3px solid var(--border); background: rgba(255, 255, 255, 0.015); border-radius: 0 6px 6px 0; }
|
||||
.ctx-section .ctx-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim);
|
||||
padding: 5px 10px 3px;
|
||||
}
|
||||
.ctx-section pre {
|
||||
margin: 0;
|
||||
padding: 6px 10px 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ctx-narrator, .ctx-ai_instructions { border-left-color: #7d8fc9; }
|
||||
.ctx-narrator .ctx-header, .ctx-ai_instructions .ctx-header { color: #7d8fc9; }
|
||||
.ctx-plot_essentials, .ctx-script_context { border-left-color: #c97dc0; }
|
||||
.ctx-plot_essentials .ctx-header, .ctx-script_context .ctx-header { color: #c97dc0; }
|
||||
.ctx-story_summary { border-left-color: #7dc9a2; }
|
||||
.ctx-story_summary .ctx-header { color: #7dc9a2; }
|
||||
.ctx-world_lore { border-left-color: #c9b47d; }
|
||||
.ctx-world_lore .ctx-header { color: #c9b47d; }
|
||||
.ctx-history, .ctx-recent_history { border-left-color: #6a6a78; }
|
||||
.ctx-authors_note, .ctx-front_memory { border-left-color: #c97d7d; }
|
||||
.ctx-authors_note .ctx-header, .ctx-front_memory .ctx-header { color: #c97d7d; }
|
||||
|
||||
/* ---------- Scripts ---------- */
|
||||
|
||||
.slot-tabs { display: flex; gap: 0; margin-bottom: 4px; }
|
||||
.slot-tabs button {
|
||||
border-radius: 0;
|
||||
border-right-width: 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.slot-tabs button:first-child { border-radius: 8px 0 0 8px; }
|
||||
.slot-tabs button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; }
|
||||
.slot-tabs button.active { color: var(--accent-bright); background: var(--bg-panel); }
|
||||
.slot-hint { color: var(--text-dim); font-size: 0.8rem; margin-bottom: 8px; }
|
||||
|
||||
.cm-editor { border: 1px solid var(--border); border-radius: 8px; font-size: 0.88rem; }
|
||||
.cm-editor.cm-focused {
|
||||
outline: none;
|
||||
border-color: var(--accent-dim);
|
||||
box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.1);
|
||||
}
|
||||
|
||||
.test-run .row { display: flex; gap: 12px; }
|
||||
.test-result {
|
||||
margin-top: 12px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.test-result .label {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.test-result pre {
|
||||
display: inline-block;
|
||||
margin: 2px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: Consolas, monospace;
|
||||
vertical-align: top;
|
||||
}
|
||||
.test-error { color: var(--danger); white-space: pre-wrap; }
|
||||
|
||||
.script-attach {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.script-attach:hover { border-color: var(--border-bright); }
|
||||
.script-attach .dim { color: var(--text-dim); font-size: 0.82rem; }
|
||||
|
||||
/* ---------- Memory bank (Phase 6) ---------- */
|
||||
|
||||
.memory-row {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.memory-row.forgotten { opacity: 0.55; }
|
||||
.memory-text { font-size: 0.9rem; white-space: pre-wrap; }
|
||||
.memory-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 5px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.memory-meta .dim { color: var(--text-dim); }
|
||||
.memory-meta .action-tools { display: inline-flex; gap: 4px; margin-left: auto; }
|
||||
.memory-meta .action-tools button {
|
||||
padding: 1px 8px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.memory-meta .action-tools button:hover { color: var(--text); }
|
||||
.memory-badge {
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.script-report { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 8px; }
|
||||
.script-error { color: var(--danger); font-size: 0.85rem; margin-bottom: 4px; white-space: pre-wrap; }
|
||||
.script-logs {
|
||||
background: var(--bg-input);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* ---------- Filter bar & tags ---------- */
|
||||
|
||||
.filter-bar { margin-bottom: 18px; }
|
||||
.search-input { max-width: 340px; }
|
||||
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.tag {
|
||||
padding: 2px 12px;
|
||||
font-size: 0.76rem;
|
||||
border-radius: 999px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.tag.active {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--accent-dim);
|
||||
box-shadow: inset 0 0 10px rgba(212, 169, 78, 0.1);
|
||||
}
|
||||
.tag.small {
|
||||
display: inline-block;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1px 9px;
|
||||
margin-right: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- Modal ---------- */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(5, 5, 9, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
animation: page-in 0.2s ease both;
|
||||
}
|
||||
.modal {
|
||||
width: min(480px, calc(100vw - 40px));
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-bright);
|
||||
border-radius: 14px;
|
||||
padding: 26px 28px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(212, 169, 78, 0.06);
|
||||
}
|
||||
.modal h2 {
|
||||
margin: 0 0 4px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.2rem;
|
||||
color: var(--accent-bright);
|
||||
}
|
||||
.modal .modal-hint { margin: 0 0 18px; color: var(--text-dim); font-size: 0.88rem; }
|
||||
.modal .modal-buttons { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
|
||||
|
||||
/* ---------- Debug log (Settings) ---------- */
|
||||
|
||||
.debug-entry {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.debug-entry-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.86rem;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.debug-entry-head .dim, .debug-entry-body .dim { color: var(--text-dim); font-size: 0.8rem; }
|
||||
.debug-status {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-bright);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.debug-status.ok { color: #7dc9a2; border-color: #7dc9a244; }
|
||||
.debug-status.error { color: var(--danger); border-color: var(--danger); }
|
||||
.debug-status.pending { color: var(--accent); border-color: var(--accent-dim); }
|
||||
.debug-entry-body { padding: 10px 12px; font-size: 0.85rem; }
|
||||
.debug-entry-body pre {
|
||||
margin: 2px 0 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 0.78rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ---------- Toast ---------- */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-panel-glass);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--text);
|
||||
padding: 11px 20px;
|
||||
border-radius: 10px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
|
||||
animation: toast-in 0.25s ease both;
|
||||
}
|
||||
.toast.ok { border-color: var(--accent); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow); }
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translate(-50%, 12px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import Home from './pages/Home.jsx'
|
||||
import Scenarios from './pages/Scenarios.jsx'
|
||||
import ScenarioEditor from './pages/ScenarioEditor.jsx'
|
||||
import Play from './pages/Play.jsx'
|
||||
import Scripts from './pages/Scripts.jsx'
|
||||
import ScriptEditor from './pages/ScriptEditor.jsx'
|
||||
import Settings from './pages/Settings.jsx'
|
||||
import './index.css'
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <App />,
|
||||
children: [
|
||||
{ index: true, element: <Home /> },
|
||||
{ path: 'scenarios', element: <Scenarios /> },
|
||||
{ path: 'scenarios/:id', element: <ScenarioEditor /> },
|
||||
{ path: 'play/:id', element: <Play /> },
|
||||
{ path: 'scripts', element: <Scripts /> },
|
||||
{ path: 'scripts/:id', element: <ScriptEditor /> },
|
||||
{ path: 'settings', element: <Settings /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { downloadJSON, pickJSONFile } from '../components'
|
||||
|
||||
export default function Home() {
|
||||
const [adventures, setAdventures] = useState(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
api.listAdventures().then(setAdventures).catch(() => setAdventures([]))
|
||||
}, [])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!adventures) return null
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return adventures
|
||||
return adventures.filter((a) =>
|
||||
`${a.title} ${a.scenario_title || ''}`.toLowerCase().includes(q))
|
||||
}, [adventures, search])
|
||||
|
||||
const remove = async (e, id) => {
|
||||
e.stopPropagation()
|
||||
if (!confirm('Delete this adventure permanently?')) return
|
||||
await api.deleteAdventure(id)
|
||||
setAdventures(adventures.filter((a) => a.id !== id))
|
||||
}
|
||||
|
||||
const exportOne = async (e, adv) => {
|
||||
e.stopPropagation()
|
||||
const bundle = await api.exportAdventure(adv.id)
|
||||
const safe = adv.title.replace(/[^\w-]+/g, '_').slice(0, 60) || 'adventure'
|
||||
downloadJSON(bundle, `${safe}.json`)
|
||||
}
|
||||
|
||||
const importOne = async () => {
|
||||
try {
|
||||
const bundle = await pickJSONFile()
|
||||
const adv = await api.importAdventure(bundle)
|
||||
navigate(`/play/${adv.id}`)
|
||||
} catch (err) {
|
||||
alert(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Adventures</h1>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={importOne}>Import</button>
|
||||
<button className="primary" onClick={() => navigate('/scenarios')}>
|
||||
+ New Adventure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adventures?.length > 0 && (
|
||||
<div className="filter-bar">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search adventures…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visible === null ? null : visible.length === 0 ? (
|
||||
<div className="empty">
|
||||
{adventures.length === 0
|
||||
? 'No adventures yet. Head to Scenarios to begin your first story.'
|
||||
: 'No adventures match your search.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{visible.map((adv) => (
|
||||
<div key={adv.id} className="card" onClick={() => navigate(`/play/${adv.id}`)}>
|
||||
<h3>{adv.title}</h3>
|
||||
<p>
|
||||
{adv.scenario_title ? `From “${adv.scenario_title}” · ` : ''}
|
||||
{adv.action_count} actions
|
||||
</p>
|
||||
<div className="meta">
|
||||
Last played {new Date(adv.updated_at + 'Z').toLocaleString()}
|
||||
<span style={{ float: 'right', display: 'inline-flex', gap: 4 }}>
|
||||
<button
|
||||
title="Export as JSON backup"
|
||||
style={{ padding: '2px 8px', fontSize: '0.75rem' }}
|
||||
onClick={(e) => exportOne(e, adv)}
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
className="danger"
|
||||
style={{ padding: '2px 8px', fontSize: '0.75rem' }}
|
||||
onClick={(e) => remove(e, adv.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { Field, StoryCardRow } from '../components'
|
||||
|
||||
const MODES = ['do', 'say', 'story']
|
||||
const PLAYER_TYPES = ['do', 'say', 'story']
|
||||
|
||||
// Models often emit light markdown emphasis; render **bold** / *italic*
|
||||
// instead of showing raw asterisks. Everything else stays plain text.
|
||||
function renderEmphasis(text) {
|
||||
const re = /\*\*([^*\n]+)\*\*|\*([^*\n]+)\*/g
|
||||
const parts = []
|
||||
let last = 0
|
||||
let match
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
if (match.index > last) parts.push(text.slice(last, match.index))
|
||||
parts.push(match[1] !== undefined
|
||||
? <b key={match.index}>{match[1]}</b>
|
||||
: <i key={match.index}>{match[2]}</i>)
|
||||
last = match.index + match[0].length
|
||||
}
|
||||
if (parts.length === 0) return text
|
||||
if (last < text.length) parts.push(text.slice(last))
|
||||
return parts
|
||||
}
|
||||
|
||||
function ReasoningBlock({ text, streaming }) {
|
||||
if (!text) return null
|
||||
return (
|
||||
<details className="reasoning" open={streaming || undefined}>
|
||||
<summary>💭 Reasoning{streaming ? '…' : ''}</summary>
|
||||
<div className="reasoning-text">{text}</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
const SECTION_LABELS = {
|
||||
narrator: 'Narrator prompt',
|
||||
script_context: 'Script context',
|
||||
ai_instructions: 'AI Instructions',
|
||||
plot_essentials: 'Plot Essentials',
|
||||
story_summary: 'Story Summary',
|
||||
used_memories: 'Used Memories (memory bank)',
|
||||
world_lore: 'World Lore (story cards)',
|
||||
history: 'Story history',
|
||||
authors_note: "Author's Note",
|
||||
recent_history: 'Recent history',
|
||||
front_memory: 'Front memory',
|
||||
}
|
||||
|
||||
function PlotPanel({ adventure, setAdventure }) {
|
||||
const saveTimer = useRef(null)
|
||||
|
||||
const setField = (field, value) => {
|
||||
setAdventure({ ...adventure, [field]: value })
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(() => api.updateAdventure(adventure.id, { [field]: value }), 600)
|
||||
}
|
||||
|
||||
const addCard = async () => {
|
||||
const card = await api.createStoryCard({ adventure_id: adventure.id })
|
||||
setAdventure({ ...adventure, story_cards: [...adventure.story_cards, card] })
|
||||
}
|
||||
|
||||
const updateCard = (card) => {
|
||||
setAdventure({
|
||||
...adventure,
|
||||
story_cards: adventure.story_cards.map((c) => (c.id === card.id ? card : c)),
|
||||
})
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(() => {
|
||||
api.updateStoryCard(card.id, {
|
||||
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
|
||||
})
|
||||
}, 600)
|
||||
}
|
||||
|
||||
const deleteCard = async (cardId) => {
|
||||
await api.deleteStoryCard(cardId)
|
||||
setAdventure({
|
||||
...adventure,
|
||||
story_cards: adventure.story_cards.filter((c) => c.id !== cardId),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Field label="Plot Essentials (Memory)" value={adventure.memory}
|
||||
onChange={(v) => setField('memory', v)} textarea
|
||||
placeholder="Key facts the AI should always remember." />
|
||||
<Field label="Author's Note" value={adventure.authors_note}
|
||||
onChange={(v) => setField('authors_note', v)} textarea rows={2}
|
||||
placeholder="Style/theme guidance, injected near the end of context." />
|
||||
<Field label="AI Instructions" value={adventure.ai_instructions}
|
||||
onChange={(v) => setField('ai_instructions', v)} textarea rows={2}
|
||||
placeholder="Behavioral guidance for the model." />
|
||||
<Field label="Story Summary" value={adventure.story_summary}
|
||||
onChange={(v) => setField('story_summary', v)} textarea
|
||||
placeholder="Running summary of events so far. Updated automatically every 15 actions when auto-summarization is on; your edits are kept as the base for the next update." />
|
||||
|
||||
<div className="page-header" style={{ marginTop: 18 }}>
|
||||
<h3 style={{ margin: 0 }}>Story Cards</h3>
|
||||
<button onClick={addCard}>+ Add</button>
|
||||
</div>
|
||||
{adventure.story_cards.length === 0 && (
|
||||
<div className="empty" style={{ padding: '12px 0' }}>No story cards yet.</div>
|
||||
)}
|
||||
{adventure.story_cards.map((card) => (
|
||||
<StoryCardRow key={card.id} card={card}
|
||||
onChange={updateCard} onDelete={() => deleteCard(card.id)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemoryRow({ memory, onChange, onDelete }) {
|
||||
const [editText, setEditText] = useState(null)
|
||||
|
||||
const save = () => {
|
||||
const text = editText.trim()
|
||||
setEditText(null)
|
||||
if (text && text !== memory.text) onChange({ text })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`memory-row ${memory.forgotten ? 'forgotten' : ''}`}>
|
||||
{editText !== null ? (
|
||||
<div className="action-edit" style={{ margin: 0 }}>
|
||||
<textarea autoFocus value={editText} rows={3}
|
||||
onChange={(e) => setEditText(e.target.value)} />
|
||||
<div className="edit-buttons">
|
||||
<button className="primary" onClick={save}>Save</button>
|
||||
<button onClick={() => setEditText(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="memory-text">{memory.text}</div>
|
||||
<div className="memory-meta">
|
||||
{memory.pinned && <span className="memory-badge">📌 pinned</span>}
|
||||
{memory.forgotten && <span className="memory-badge">forgotten</span>}
|
||||
{!memory.embedded && !memory.forgotten && (
|
||||
<span className="memory-badge" title="Embedded on the next turn">not embedded yet</span>
|
||||
)}
|
||||
{memory.source_start != null && (
|
||||
<span className="dim">actions {memory.source_start}–{memory.source_end}</span>
|
||||
)}
|
||||
<span className="dim">used {memory.use_count}×</span>
|
||||
<span className="action-tools">
|
||||
<button title={memory.pinned ? 'Unpin' : 'Pin (always included in context)'}
|
||||
onClick={() => onChange({ pinned: !memory.pinned })}>📌</button>
|
||||
{memory.forgotten && (
|
||||
<button title="Restore this memory"
|
||||
onClick={() => onChange({ forgotten: false })}>↩</button>
|
||||
)}
|
||||
<button title="Edit" onClick={() => setEditText(memory.text)}>✎</button>
|
||||
<button title="Delete" onClick={onDelete}>✕</button>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemoryPanel({ adventure, setAdventure, refreshKey }) {
|
||||
const [memories, setMemories] = useState(null)
|
||||
const [newText, setNewText] = useState('')
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.listMemories(adventure.id).then(setMemories).catch(() => setMemories([]))
|
||||
}, [adventure.id])
|
||||
|
||||
// Summarization runs in the background after a turn, so also refresh shortly after.
|
||||
useEffect(() => {
|
||||
load()
|
||||
const timer = setTimeout(load, 4000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [load, refreshKey])
|
||||
|
||||
const setFlag = async (field, value) => {
|
||||
setAdventure({ ...adventure, [field]: value })
|
||||
await api.updateAdventure(adventure.id, { [field]: value })
|
||||
}
|
||||
|
||||
const change = async (memory, data) => {
|
||||
const updated = await api.updateMemory(adventure.id, memory.id, data)
|
||||
setMemories((prev) => prev.map((m) => (m.id === memory.id ? updated : m)))
|
||||
}
|
||||
|
||||
const remove = async (memory) => {
|
||||
await api.deleteMemory(adventure.id, memory.id)
|
||||
setMemories((prev) => prev.filter((m) => m.id !== memory.id))
|
||||
}
|
||||
|
||||
const add = async () => {
|
||||
const text = newText.trim()
|
||||
if (!text) return
|
||||
setNewText('')
|
||||
const memory = await api.createMemory(adventure.id, text)
|
||||
setMemories((prev) => [...prev, memory])
|
||||
}
|
||||
|
||||
const active = memories?.filter((m) => !m.forgotten) ?? []
|
||||
const forgotten = memories?.filter((m) => m.forgotten) ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="script-attach">
|
||||
<input type="checkbox" checked={adventure.auto_summarize}
|
||||
onChange={(e) => setFlag('auto_summarize', e.target.checked)} />
|
||||
<span>Auto-summarization</span>
|
||||
<span className="dim">memories every 6 actions, Story Summary every 15</span>
|
||||
</label>
|
||||
<label className="script-attach">
|
||||
<input type="checkbox" checked={adventure.memory_bank_enabled}
|
||||
onChange={(e) => setFlag('memory_bank_enabled', e.target.checked)} />
|
||||
<span>Memory Bank</span>
|
||||
<span className="dim">retrieve relevant memories into context (needs an embedding model)</span>
|
||||
</label>
|
||||
|
||||
<div className="page-header" style={{ marginTop: 18 }}>
|
||||
<h3 style={{ margin: 0 }}>Memories {memories && `(${active.length})`}</h3>
|
||||
<button onClick={load}>Refresh</button>
|
||||
</div>
|
||||
{!memories && <div className="empty" style={{ padding: '12px 0' }}>Loading…</div>}
|
||||
{memories && active.length === 0 && (
|
||||
<div className="empty" style={{ padding: '12px 0' }}>
|
||||
No memories yet. They are generated automatically as you play (from action 12
|
||||
onward), or add one below.
|
||||
</div>
|
||||
)}
|
||||
{active.map((m) => (
|
||||
<MemoryRow key={m.id} memory={m}
|
||||
onChange={(data) => change(m, data)} onDelete={() => remove(m)} />
|
||||
))}
|
||||
|
||||
<div className="input-bar" style={{ marginTop: 10 }}>
|
||||
<input type="text" value={newText} placeholder="Add a memory manually…"
|
||||
onChange={(e) => setNewText(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') add() }} />
|
||||
<button onClick={add}>+ Add</button>
|
||||
</div>
|
||||
|
||||
{forgotten.length > 0 && (
|
||||
<>
|
||||
<div className="page-header" style={{ marginTop: 18 }}>
|
||||
<h3 style={{ margin: 0 }}>Forgotten ({forgotten.length})</h3>
|
||||
</div>
|
||||
{forgotten.map((m) => (
|
||||
<MemoryRow key={m.id} memory={m}
|
||||
onChange={(data) => change(m, data)} onDelete={() => remove(m)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScriptsPanel({ advId }) {
|
||||
const [scripts, setScripts] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.listAdventureScripts(advId).then(setScripts).catch(() => setScripts([]))
|
||||
}, [advId])
|
||||
|
||||
const toggle = async (script) => {
|
||||
const updated = await api.updateAdventureScript(advId, script.id, { enabled: !script.enabled })
|
||||
setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s)))
|
||||
}
|
||||
|
||||
if (!scripts) return <div className="empty">Loading…</div>
|
||||
if (scripts.length === 0) {
|
||||
return (
|
||||
<div className="empty">
|
||||
No scripts on this adventure. Attach scripts to a scenario before starting an
|
||||
adventure from it.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{scripts.map((s) => (
|
||||
<label key={s.id} className="script-attach">
|
||||
<input type="checkbox" checked={s.enabled} onChange={() => toggle(s)} />
|
||||
<span>{s.name}</span>
|
||||
<span className="dim">{s.description}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScriptReport({ script }) {
|
||||
if (!script || (!script.logs?.length && !script.errors?.length && !script.context_changed)) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="script-report">
|
||||
<div className="ctx-header" style={{ padding: '4px 0 2px' }}><span>Scripts</span></div>
|
||||
{script.errors?.map((e, i) => <div key={i} className="script-error">⚠ {e}</div>)}
|
||||
{script.logs?.length > 0 && (
|
||||
<pre className="script-logs">{script.logs.join('\n')}</pre>
|
||||
)}
|
||||
{script.context_changed && (
|
||||
<>
|
||||
<div className="ctx-section ctx-script_context">
|
||||
<div className="ctx-header"><span>Context before script</span></div>
|
||||
<pre>{script.context_before}</pre>
|
||||
</div>
|
||||
<div className="ctx-section ctx-script_context">
|
||||
<div className="ctx-header"><span>Context after script (sent to AI)</span></div>
|
||||
<pre>{script.context_after}</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
|
||||
const [report, setReport] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setError(null)
|
||||
const load = inspectActionId
|
||||
? api.getActionContext(advId, inspectActionId)
|
||||
: api.getAdventureContext(advId)
|
||||
load.then(setReport).catch((err) => { setReport(null); setError(err.message) })
|
||||
}, [advId, inspectActionId, refreshKey])
|
||||
|
||||
if (error) return <div className="empty">{error}</div>
|
||||
if (!report) return <div className="empty">Loading…</div>
|
||||
|
||||
const { tokens, cards, history, sections } = report
|
||||
const overBudget = tokens.total > tokens.budget
|
||||
|
||||
return (
|
||||
<div className="insights">
|
||||
<div className="insights-meta">
|
||||
{inspectActionId ? (
|
||||
<div className="insights-mode">
|
||||
Snapshot of a past turn
|
||||
<button onClick={onClearInspect} style={{ marginLeft: 8 }}>Back to next turn</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="insights-mode">What will be sent on the next turn</div>
|
||||
)}
|
||||
<div className={`token-total ${overBudget ? 'over' : ''}`}>
|
||||
{tokens.total} / {tokens.budget} tokens
|
||||
</div>
|
||||
<div className="token-bar">
|
||||
<div className="token-bar-fill"
|
||||
style={{ width: `${Math.min(100, (tokens.total / tokens.budget) * 100)}%` }} />
|
||||
</div>
|
||||
<div className="insights-history">
|
||||
History: {history.included} of {history.total} actions in context
|
||||
{history.total > history.included && ' (older history trimmed)'}
|
||||
{history.oldest_truncated && ' — oldest entry cut mid-text'}
|
||||
</div>
|
||||
{cards.length > 0 && (
|
||||
<div className="insights-cards">
|
||||
{cards.map((c, i) => (
|
||||
<div key={i} className={c.included ? '' : 'dropped'}>
|
||||
▸ <b>{c.name || '(unnamed card)'}</b> triggered on “{c.keyword}”
|
||||
{!c.included && ' — dropped (over card budget)'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{report.memories && (
|
||||
<div className="insights-cards">
|
||||
{report.memories.error && (
|
||||
<div className="dropped">⚠ Memory bank: {report.memories.error}</div>
|
||||
)}
|
||||
{report.memories.used?.map((m, i) => (
|
||||
<div key={i}>
|
||||
▸ memory retrieved ({m.pinned ? 'pinned' : `similarity ${m.similarity.toFixed(2)}`}):
|
||||
{' '}{m.text.length > 90 ? m.text.slice(0, 90) + '…' : m.text}
|
||||
</div>
|
||||
))}
|
||||
{!report.memories.error && report.memories.used?.length === 0 && (
|
||||
<div className="dim">Memory bank on — no memories retrieved.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sections.map((s, i) => (
|
||||
<div key={i} className={`ctx-section ctx-${s.label}`}>
|
||||
<div className="ctx-header">
|
||||
<span>{SECTION_LABELS[s.label] || s.label}</span>
|
||||
<span>{s.tokens} tok</span>
|
||||
</div>
|
||||
<pre>{s.text}</pre>
|
||||
</div>
|
||||
))}
|
||||
<ScriptReport script={report.script} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Play() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [adventure, setAdventure] = useState(null)
|
||||
const [actions, setActions] = useState([])
|
||||
const [mode, setMode] = useState('do')
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(null)
|
||||
const [reasoningStream, setReasoningStream] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [toast, setToast] = useState(null)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [panel, setPanel] = useState(null) // null | 'plot' | 'insights'
|
||||
const [inspectActionId, setInspectActionId] = useState(null)
|
||||
const storyEndRef = useRef(null)
|
||||
const abortRef = useRef(null)
|
||||
const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom
|
||||
|
||||
useEffect(() => {
|
||||
api.getAdventure(id)
|
||||
.then((adv) => { setAdventure(adv); setActions(adv.actions) })
|
||||
.catch(() => navigate('/'))
|
||||
}, [id, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
pinnedRef.current =
|
||||
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 120
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (pinnedRef.current) {
|
||||
storyEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
}
|
||||
}, [actions, streaming, reasoningStream])
|
||||
|
||||
const handleScriptReport = useCallback((script) => {
|
||||
if (!script) return
|
||||
if (script.errors?.length) {
|
||||
setToast({ text: `Script error: ${script.errors[0]}`, isError: true })
|
||||
} else if (script.message) {
|
||||
setToast({ text: script.message, isError: false })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleEvent = useCallback((event) => {
|
||||
if (event.type === 'player') {
|
||||
setActions((prev) => [...prev, event.action])
|
||||
} else if (event.type === 'chunk') {
|
||||
setStreaming((prev) => (prev ?? '') + event.text)
|
||||
} else if (event.type === 'reasoning') {
|
||||
setReasoningStream((prev) => (prev ?? '') + event.text)
|
||||
} else if (event.type === 'done') {
|
||||
setStreaming(null)
|
||||
setReasoningStream(null)
|
||||
setActions((prev) => [...prev, event.action])
|
||||
handleScriptReport(event.script)
|
||||
} else if (event.type === 'stopped') {
|
||||
setStreaming(null)
|
||||
setReasoningStream(null)
|
||||
handleScriptReport(event.script)
|
||||
} else if (event.type === 'error') {
|
||||
setStreaming(null)
|
||||
setReasoningStream(null)
|
||||
setToast({ text: event.detail, isError: true })
|
||||
}
|
||||
}, [handleScriptReport])
|
||||
|
||||
async function runTurn(run) {
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
setBusy(true)
|
||||
setToast(null)
|
||||
setStreaming('')
|
||||
pinnedRef.current = true
|
||||
try {
|
||||
await run(controller.signal)
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
setToast({ text: 'Generation stopped.', isError: false })
|
||||
} else {
|
||||
setToast({ text: err.message, isError: true })
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null
|
||||
setStreaming(null)
|
||||
setReasoningStream(null)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
abortRef.current?.abort()
|
||||
}
|
||||
|
||||
function send(type = mode) {
|
||||
const text = input.trim()
|
||||
const payload = { type: text ? type : 'continue', text }
|
||||
setInput('')
|
||||
runTurn((signal) => api.sendAction(id, payload, handleEvent, signal))
|
||||
}
|
||||
|
||||
function retry() {
|
||||
setActions((prev) =>
|
||||
prev.length && prev[prev.length - 1].type === 'ai' ? prev.slice(0, -1) : prev)
|
||||
runTurn((signal) => api.retry(id, handleEvent, signal))
|
||||
}
|
||||
|
||||
async function undo() {
|
||||
setToast(null)
|
||||
try {
|
||||
setActions(await api.undo(id))
|
||||
} catch (err) {
|
||||
setToast({ text: err.message, isError: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Z undo / Ctrl+R retry, ignored while typing in a field.
|
||||
useEffect(() => {
|
||||
const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai'
|
||||
const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start'
|
||||
const onKey = (e) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || busy) return
|
||||
if (e.target.closest?.('input, textarea, select, [contenteditable]')) return
|
||||
if (e.key.toLowerCase() === 'z' && canUndo) {
|
||||
e.preventDefault()
|
||||
undo()
|
||||
} else if (e.key.toLowerCase() === 'r' && lastIsAi) {
|
||||
e.preventDefault()
|
||||
retry()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
})
|
||||
|
||||
async function saveEdit() {
|
||||
const { id: actionId, text } = editing
|
||||
setEditing(null)
|
||||
try {
|
||||
const updated = await api.updateAction(id, actionId, text)
|
||||
setActions((prev) => prev.map((a) => (a.id === actionId ? updated : a)))
|
||||
} catch (err) {
|
||||
setToast({ text: err.message, isError: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAction(actionId) {
|
||||
try {
|
||||
await api.deleteAction(id, actionId)
|
||||
setActions((prev) => prev.filter((a) => a.id !== actionId))
|
||||
} catch (err) {
|
||||
setToast({ text: err.message, isError: true })
|
||||
}
|
||||
}
|
||||
|
||||
function inspect(actionId) {
|
||||
setInspectActionId(actionId)
|
||||
setPanel('insights')
|
||||
}
|
||||
|
||||
if (!adventure) return null
|
||||
|
||||
const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai'
|
||||
const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start'
|
||||
|
||||
return (
|
||||
<div className={`play-layout ${panel ? 'with-panel' : ''}`}>
|
||||
<div className="page play-page">
|
||||
<div className="page-header">
|
||||
<h1>{adventure.title}</h1>
|
||||
<div className="panel-toggles">
|
||||
<button className={panel === 'plot' ? 'active' : ''}
|
||||
onClick={() => setPanel(panel === 'plot' ? null : 'plot')}>Plot</button>
|
||||
<button className={panel === 'memory' ? 'active' : ''}
|
||||
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
|
||||
<button className={panel === 'scripts' ? 'active' : ''}
|
||||
onClick={() => setPanel(panel === 'scripts' ? null : 'scripts')}>Scripts</button>
|
||||
<button className={panel === 'insights' ? 'active' : ''}
|
||||
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
|
||||
Insights
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="story">
|
||||
{actions.length === 0 && streaming === null && (
|
||||
<div className="empty">A blank page. Type something below to begin your story.</div>
|
||||
)}
|
||||
{actions.map((action) =>
|
||||
editing?.id === action.id ? (
|
||||
<div key={action.id} className="action-edit">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={editing.text}
|
||||
onChange={(e) => setEditing({ ...editing, text: e.target.value })}
|
||||
/>
|
||||
<div className="edit-buttons">
|
||||
<button className="primary" onClick={saveEdit}>Save</button>
|
||||
<button onClick={() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={action.id}
|
||||
className={`action ${PLAYER_TYPES.includes(action.type) ? 'player' : ''}`}>
|
||||
<ReasoningBlock text={action.reasoning} />
|
||||
{renderEmphasis(action.text)}
|
||||
{!busy && (
|
||||
<span className="action-tools">
|
||||
{action.type === 'ai' && (
|
||||
<button title="View the exact prompt that produced this"
|
||||
onClick={() => inspect(action.id)}>🔍</button>
|
||||
)}
|
||||
<button title="Edit"
|
||||
onClick={() => setEditing({ id: action.id, text: action.text })}>✎</button>
|
||||
<button title="Delete" onClick={() => removeAction(action.id)}>✕</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{streaming !== null && (
|
||||
<div className="action streaming">
|
||||
<ReasoningBlock text={reasoningStream} streaming />
|
||||
{renderEmphasis(streaming)}
|
||||
<span className="cursor">▋</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={storyEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="play-controls">
|
||||
<div className="turn-buttons">
|
||||
<button onClick={() => send('continue')} disabled={busy}>Continue ▸</button>
|
||||
<button onClick={retry} disabled={busy || !lastIsAi} title="Ctrl+R">↻ Retry</button>
|
||||
<button onClick={undo} disabled={busy || !canUndo} title="Ctrl+Z">↶ Undo</button>
|
||||
</div>
|
||||
<div className="input-bar">
|
||||
<div className="mode-select">
|
||||
{MODES.map((m) => (
|
||||
<button key={m} className={mode === m ? 'active' : ''}
|
||||
onClick={() => setMode(m)} disabled={busy}>
|
||||
{m[0].toUpperCase() + m.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
disabled={busy}
|
||||
placeholder={
|
||||
mode === 'do' ? 'What do you do?'
|
||||
: mode === 'say' ? 'What do you say?'
|
||||
: 'What happens next?'
|
||||
}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !busy) send() }}
|
||||
/>
|
||||
{busy ? (
|
||||
<button className="danger" onClick={stopGeneration}>■ Stop</button>
|
||||
) : (
|
||||
<button className="primary" onClick={() => send()}>Send</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{panel && (
|
||||
<div className="side-panel">
|
||||
<div className="side-panel-header">
|
||||
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', scripts: 'Scripts', insights: 'Insights' }[panel]}</h2>
|
||||
<button onClick={() => setPanel(null)}>✕</button>
|
||||
</div>
|
||||
{panel === 'plot' ? (
|
||||
<PlotPanel adventure={adventure} setAdventure={setAdventure} />
|
||||
) : panel === 'memory' ? (
|
||||
<MemoryPanel adventure={adventure} setAdventure={setAdventure}
|
||||
refreshKey={actions.length} />
|
||||
) : panel === 'scripts' ? (
|
||||
<ScriptsPanel advId={id} />
|
||||
) : (
|
||||
<InsightsPanel advId={id} inspectActionId={inspectActionId}
|
||||
onClearInspect={() => setInspectActionId(null)} refreshKey={actions.length} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toast && (
|
||||
<div className={`toast ${toast.isError ? '' : 'ok'}`}>
|
||||
{toast.text}
|
||||
{toast.isError && (
|
||||
<button style={{ marginLeft: 12 }} onClick={retry}>Retry</button>
|
||||
)}
|
||||
<button style={{ marginLeft: 8 }} onClick={() => setToast(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { Field, StoryCardRow, downloadJSON } from '../components'
|
||||
|
||||
export default function ScenarioEditor() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [scenario, setScenario] = useState(null)
|
||||
const [allScripts, setAllScripts] = useState([])
|
||||
const [status, setStatus] = useState('')
|
||||
const saveTimer = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.getScenario(id).then(setScenario).catch(() => navigate('/scenarios'))
|
||||
api.listScripts().then(setAllScripts).catch(() => {})
|
||||
}, [id, navigate])
|
||||
|
||||
const setField = (field, value) => {
|
||||
const next = { ...scenario, [field]: value }
|
||||
setScenario(next)
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(async () => {
|
||||
await api.updateScenario(id, { [field]: value })
|
||||
setStatus('Saved')
|
||||
setTimeout(() => setStatus(''), 1500)
|
||||
}, 600)
|
||||
}
|
||||
|
||||
const addCard = async () => {
|
||||
const card = await api.createStoryCard({ scenario_id: Number(id) })
|
||||
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
|
||||
}
|
||||
|
||||
const updateCard = (card) => {
|
||||
setScenario({
|
||||
...scenario,
|
||||
story_cards: scenario.story_cards.map((c) => (c.id === card.id ? card : c)),
|
||||
})
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(() => {
|
||||
api.updateStoryCard(card.id, {
|
||||
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
|
||||
})
|
||||
}, 600)
|
||||
}
|
||||
|
||||
const deleteCard = async (cardId) => {
|
||||
await api.deleteStoryCard(cardId)
|
||||
setScenario({ ...scenario, story_cards: scenario.story_cards.filter((c) => c.id !== cardId) })
|
||||
}
|
||||
|
||||
const toggleScript = async (scriptId) => {
|
||||
const current = scenario.scripts.map((s) => s.id)
|
||||
const next = current.includes(scriptId)
|
||||
? current.filter((sid) => sid !== scriptId)
|
||||
: [...current, scriptId]
|
||||
const updated = await api.updateScenario(id, { script_ids: next })
|
||||
setScenario(updated)
|
||||
}
|
||||
|
||||
const exportScenario = async () => {
|
||||
downloadJSON(await api.exportScenario(id), `${scenario.title.replace(/\W+/g, '-')}.json`)
|
||||
}
|
||||
|
||||
const deleteScenario = async () => {
|
||||
if (!confirm('Delete this scenario permanently?')) return
|
||||
await api.deleteScenario(id)
|
||||
navigate('/scenarios')
|
||||
}
|
||||
|
||||
const play = async () => {
|
||||
const adv = await api.createAdventure({ scenario_id: Number(id) })
|
||||
navigate(`/play/${adv.id}`)
|
||||
}
|
||||
|
||||
if (!scenario) return null
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Edit Scenario</h1>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{status}</span>
|
||||
<button onClick={exportScenario}>Export</button>
|
||||
<button className="danger" onClick={deleteScenario}>Delete</button>
|
||||
<button className="primary" onClick={play}>Play</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Title" value={scenario.title} onChange={(v) => setField('title', v)} />
|
||||
<Field label="Description" value={scenario.description} onChange={(v) => setField('description', v)}
|
||||
textarea placeholder="Shown in the scenario list; not sent to the AI." />
|
||||
<Field label="Opening Prompt" value={scenario.prompt} onChange={(v) => setField('prompt', v)}
|
||||
textarea rows={6} placeholder="The opening story text. Supports ${placeholders} (Phase 5)." />
|
||||
<Field label="Plot Essentials (Memory)" value={scenario.memory} onChange={(v) => setField('memory', v)}
|
||||
textarea placeholder="Key facts the AI should always remember." />
|
||||
<Field label="Author's Note" value={scenario.authors_note} onChange={(v) => setField('authors_note', v)}
|
||||
textarea rows={2} placeholder="Style/theme guidance, injected near the end of context." />
|
||||
<Field label="AI Instructions" value={scenario.ai_instructions} onChange={(v) => setField('ai_instructions', v)}
|
||||
textarea rows={2} placeholder="Behavioral guidance for the model (always included)." />
|
||||
<Field label="Tags" value={scenario.tags} onChange={(v) => setField('tags', v)} placeholder="fantasy, mystery" />
|
||||
|
||||
<div className="page-header" style={{ marginTop: 28 }}>
|
||||
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Story Cards</h2>
|
||||
<button onClick={addCard}>+ Add Card</button>
|
||||
</div>
|
||||
{scenario.story_cards.length === 0 && (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
No story cards. Cards inject their Entry into context when a trigger word appears in the story.
|
||||
</div>
|
||||
)}
|
||||
{scenario.story_cards.map((card) => (
|
||||
<StoryCardRow key={card.id} card={card}
|
||||
onChange={updateCard} onDelete={() => deleteCard(card.id)} />
|
||||
))}
|
||||
|
||||
<div className="page-header" style={{ marginTop: 28 }}>
|
||||
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Attached Scripts</h2>
|
||||
</div>
|
||||
{allScripts.length === 0 ? (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
No scripts in your library. Create some on the Scripts page.
|
||||
</div>
|
||||
) : (
|
||||
allScripts.map((s) => (
|
||||
<label key={s.id} className="script-attach">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scenario.scripts.some((att) => att.id === s.id)}
|
||||
onChange={() => toggleScript(s.id)}
|
||||
/>
|
||||
<span>{s.name}</span>
|
||||
<span className="dim">{s.description}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { extractPlaceholders, pickJSONFile, PlaceholderModal } from '../components'
|
||||
|
||||
function splitTags(tags) {
|
||||
return (tags || '').split(',').map((t) => t.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
export default function Scenarios() {
|
||||
const [scenarios, setScenarios] = useState(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [tagFilter, setTagFilter] = useState(null)
|
||||
const [pending, setPending] = useState(null) // { scenario, names } awaiting placeholder values
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
api.listScenarios().then(setScenarios).catch(() => setScenarios([]))
|
||||
}, [])
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
const tags = new Set()
|
||||
for (const sc of scenarios || []) splitTags(sc.tags).forEach((t) => tags.add(t))
|
||||
return [...tags].sort()
|
||||
}, [scenarios])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!scenarios) return null
|
||||
const q = search.trim().toLowerCase()
|
||||
return scenarios.filter((sc) => {
|
||||
if (tagFilter && !splitTags(sc.tags).includes(tagFilter)) return false
|
||||
if (q && !`${sc.title} ${sc.description} ${sc.tags}`.toLowerCase().includes(q)) return false
|
||||
return true
|
||||
})
|
||||
}, [scenarios, search, tagFilter])
|
||||
|
||||
const createScenario = async () => {
|
||||
const scenario = await api.createScenario({ title: 'New Scenario' })
|
||||
navigate(`/scenarios/${scenario.id}`)
|
||||
}
|
||||
|
||||
const begin = async (scenarioId, placeholders = {}) => {
|
||||
const adv = await api.createAdventure({ scenario_id: scenarioId, placeholders })
|
||||
navigate(`/play/${adv.id}`)
|
||||
}
|
||||
|
||||
const startAdventure = async (e, scenarioId) => {
|
||||
e.stopPropagation()
|
||||
const scenario = await api.getScenario(scenarioId)
|
||||
const names = extractPlaceholders(
|
||||
scenario.prompt, scenario.memory, scenario.authors_note, scenario.ai_instructions,
|
||||
...scenario.story_cards.map((c) => c.entry),
|
||||
)
|
||||
if (names.length === 0) return begin(scenarioId)
|
||||
setPending({ scenario, names })
|
||||
}
|
||||
|
||||
const startBlank = async () => {
|
||||
const adv = await api.createAdventure({ title: 'Blank Adventure' })
|
||||
navigate(`/play/${adv.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Scenarios</h1>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={startBlank}>Blank Adventure</button>
|
||||
<button onClick={async () => {
|
||||
try {
|
||||
const bundle = await pickJSONFile()
|
||||
const { scenario, unmapped_keys } = await api.importScenario(bundle)
|
||||
if (unmapped_keys.length) alert(`Imported. Unmapped fields ignored: ${unmapped_keys.join(', ')}`)
|
||||
navigate(`/scenarios/${scenario.id}`)
|
||||
} catch (err) { alert(err.message) }
|
||||
}}>Import</button>
|
||||
<button className="primary" onClick={createScenario}>+ New Scenario</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-bar">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search scenarios…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{allTags.length > 0 && (
|
||||
<div className="tag-row">
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
className={`tag ${tagFilter === tag ? 'active' : ''}`}
|
||||
onClick={() => setTagFilter(tagFilter === tag ? null : tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visible === null ? null : visible.length === 0 ? (
|
||||
<div className="empty">
|
||||
{scenarios.length === 0
|
||||
? 'No scenarios yet. Create one to define a reusable story template.'
|
||||
: 'No scenarios match your search.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{visible.map((sc) => (
|
||||
<div key={sc.id} className="card" onClick={() => navigate(`/scenarios/${sc.id}`)}>
|
||||
<h3>{sc.title}</h3>
|
||||
<p>{sc.description || 'No description'}</p>
|
||||
<div className="meta">
|
||||
{splitTags(sc.tags).map((tag) => (
|
||||
<span key={tag} className="tag small">{tag}</span>
|
||||
))}
|
||||
<button
|
||||
className="primary"
|
||||
style={{ float: 'right', padding: '3px 10px', fontSize: '0.78rem' }}
|
||||
onClick={(e) => startAdventure(e, sc.id)}
|
||||
>
|
||||
Play
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pending && (
|
||||
<PlaceholderModal
|
||||
title={pending.scenario.title}
|
||||
names={pending.names}
|
||||
onCancel={() => setPending(null)}
|
||||
onSubmit={(values) => { setPending(null); begin(pending.scenario.id, values) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { api } from '../api'
|
||||
import { Field, downloadJSON } from '../components'
|
||||
|
||||
const SLOTS = [
|
||||
{ key: 'library_js', label: 'Library', hint: 'Shared code prepended to all three hooks.' },
|
||||
{ key: 'input_js', label: 'Input', hint: "onInput — modifies the player's input before context construction." },
|
||||
{ key: 'context_js', label: 'Context', hint: 'onModelContext — modifies the assembled text sent to the model.' },
|
||||
{ key: 'output_js', label: 'Output', hint: 'onOutput — modifies the model output before it is shown.' },
|
||||
]
|
||||
|
||||
export default function ScriptEditor() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [script, setScript] = useState(null)
|
||||
const [slot, setSlot] = useState('input_js')
|
||||
const [status, setStatus] = useState('')
|
||||
const saveTimer = useRef(null)
|
||||
|
||||
// Test-run state
|
||||
const [testHook, setTestHook] = useState('input')
|
||||
const [testText, setTestText] = useState('> You look around.')
|
||||
const [testState, setTestState] = useState('{}')
|
||||
const [testResult, setTestResult] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.getScript(id).then(setScript).catch(() => navigate('/scripts'))
|
||||
}, [id, navigate])
|
||||
|
||||
const setField = (field, value) => {
|
||||
setScript((prev) => ({ ...prev, [field]: value }))
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(async () => {
|
||||
await api.updateScript(id, { [field]: value })
|
||||
setStatus('Saved')
|
||||
setTimeout(() => setStatus(''), 1500)
|
||||
}, 600)
|
||||
}
|
||||
|
||||
const runTest = async () => {
|
||||
let state
|
||||
try {
|
||||
state = JSON.parse(testState || '{}')
|
||||
} catch {
|
||||
setTestResult({ error: 'Test state is not valid JSON' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
setTestResult(await api.testScript(id, { hook: testHook, text: testText, state }))
|
||||
} catch (err) {
|
||||
setTestResult({ error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
const exportScript = async () => {
|
||||
downloadJSON(await api.exportScript(id), `${script.name.replace(/\W+/g, '-')}.json`)
|
||||
}
|
||||
|
||||
const deleteScript = async () => {
|
||||
if (!confirm('Delete this script permanently?')) return
|
||||
await api.deleteScript(id)
|
||||
navigate('/scripts')
|
||||
}
|
||||
|
||||
if (!script) return null
|
||||
|
||||
const activeSlot = SLOTS.find((s) => s.key === slot)
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Edit Script</h1>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{status}</span>
|
||||
<button onClick={exportScript}>Export</button>
|
||||
<button className="danger" onClick={deleteScript}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Name" value={script.name} onChange={(v) => setField('name', v)} />
|
||||
<Field label="Description" value={script.description} onChange={(v) => setField('description', v)}
|
||||
textarea rows={2} placeholder="What this script does." />
|
||||
|
||||
<div className="slot-tabs">
|
||||
{SLOTS.map((s) => (
|
||||
<button key={s.key} className={slot === s.key ? 'active' : ''} onClick={() => setSlot(s.key)}>
|
||||
{s.label}{script[s.key].trim() ? ' •' : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="slot-hint">{activeSlot.hint}</div>
|
||||
<CodeMirror
|
||||
value={script[slot]}
|
||||
height="360px"
|
||||
theme={oneDark}
|
||||
extensions={[javascript()]}
|
||||
onChange={(value) => setField(slot, value)}
|
||||
/>
|
||||
|
||||
<div className="page-header" style={{ marginTop: 26 }}>
|
||||
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Test Run</h2>
|
||||
</div>
|
||||
<div className="test-run">
|
||||
<div className="row">
|
||||
<label className="field" style={{ flex: '0 0 140px' }}>
|
||||
<span className="label">Hook</span>
|
||||
<select value={testHook} onChange={(e) => setTestHook(e.target.value)}>
|
||||
<option value="input">Input</option>
|
||||
<option value="context">Context</option>
|
||||
<option value="output">Output</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Sample text</span>
|
||||
<input type="text" value={testText} onChange={(e) => setTestText(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span className="label">State (JSON)</span>
|
||||
<input type="text" value={testState} onChange={(e) => setTestState(e.target.value)} />
|
||||
</label>
|
||||
<button className="primary" onClick={runTest}>Run</button>
|
||||
{testResult && (
|
||||
<div className="test-result">
|
||||
{testResult.error ? (
|
||||
<div className="test-error">{testResult.error}</div>
|
||||
) : (
|
||||
<>
|
||||
<div><span className="label">text</span> <pre>{testResult.text}</pre></div>
|
||||
{testResult.stop && <div><span className="label">stop</span> true — the AI call would be skipped</div>}
|
||||
<div><span className="label">state</span> <pre>{JSON.stringify(testResult.state)}</pre></div>
|
||||
{testResult.storyCards.length > 0 && (
|
||||
<div><span className="label">storyCards</span> <pre>{JSON.stringify(testResult.storyCards, null, 1)}</pre></div>
|
||||
)}
|
||||
{testResult.logs.length > 0 && (
|
||||
<div><span className="label">logs</span> <pre>{testResult.logs.join('\n')}</pre></div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { downloadJSON, pickJSONFile } from '../components'
|
||||
|
||||
export default function Scripts() {
|
||||
const [scripts, setScripts] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
api.listScripts().then(setScripts).catch(() => setScripts([]))
|
||||
}, [])
|
||||
|
||||
const createScript = async () => {
|
||||
const script = await api.createScript({
|
||||
name: 'New Script',
|
||||
input_js: '// onInput — modify the player\'s input\nconst modifier = (text) => {\n return { text }\n}\nmodifier(text)\n',
|
||||
})
|
||||
navigate(`/scripts/${script.id}`)
|
||||
}
|
||||
|
||||
const importScript = async () => {
|
||||
try {
|
||||
const bundle = await pickJSONFile()
|
||||
const script = await api.importScript(bundle)
|
||||
navigate(`/scripts/${script.id}`)
|
||||
} catch (err) {
|
||||
alert(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Scripts</h1>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={importScript}>Import</button>
|
||||
<button className="primary" onClick={createScript}>+ New Script</button>
|
||||
</div>
|
||||
</div>
|
||||
{scripts === null ? null : scripts.length === 0 ? (
|
||||
<div className="empty">
|
||||
No scripts yet. Scripts are AI Dungeon-compatible JavaScript modifiers
|
||||
(onInput / onModelContext / onOutput) you can attach to scenarios.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{scripts.map((s) => (
|
||||
<div key={s.id} className="card" onClick={() => navigate(`/scripts/${s.id}`)}>
|
||||
<h3>{s.name}</h3>
|
||||
<p>{s.description || 'No description'}</p>
|
||||
<div className="meta">
|
||||
{['library', 'input', 'context', 'output']
|
||||
.filter((slot) => s[`${slot}_js`].trim())
|
||||
.join(' · ') || 'empty'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
function DebugLog() {
|
||||
const [entries, setEntries] = useState(null)
|
||||
const [openId, setOpenId] = useState(null)
|
||||
|
||||
const load = () => api.getDebugRequests().then(setEntries).catch(() => setEntries([]))
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
if (!entries) return null
|
||||
return (
|
||||
<div className="debug-log">
|
||||
<div className="page-header" style={{ marginTop: 28 }}>
|
||||
<h3 style={{ margin: 0 }}>Recent AI requests</h3>
|
||||
<button onClick={load}>Refresh</button>
|
||||
</div>
|
||||
{entries.length === 0 && (
|
||||
<div className="empty" style={{ padding: '16px 0' }}>
|
||||
No requests yet this session. Play a turn and refresh.
|
||||
</div>
|
||||
)}
|
||||
{entries.map((e) => (
|
||||
<div key={e.id} className="debug-entry">
|
||||
<div className="debug-entry-head" onClick={() => setOpenId(openId === e.id ? null : e.id)}>
|
||||
<span className={`debug-status ${e.status}`}>{e.status}</span>
|
||||
<span>{e.model}</span>
|
||||
<span className="dim">{new Date(e.time).toLocaleTimeString()}</span>
|
||||
<span className="dim" style={{ marginLeft: 'auto' }}>{openId === e.id ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
{openId === e.id && (
|
||||
<div className="debug-entry-body">
|
||||
<div className="dim">{e.url}</div>
|
||||
{e.error && <div className="test-error">{e.error}</div>}
|
||||
<div className="ctx-header" style={{ padding: '6px 0 2px' }}><span>Request</span></div>
|
||||
<pre>{JSON.stringify(e.request, null, 2)}</pre>
|
||||
<div className="ctx-header" style={{ padding: '6px 0 2px' }}><span>Response</span></div>
|
||||
<pre>{e.response || '(empty)'}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const [settings, setSettings] = useState(null)
|
||||
const [testResult, setTestResult] = useState(null)
|
||||
const [saved, setSaved] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(setSettings)
|
||||
}, [])
|
||||
|
||||
const setField = (field, value) => setSettings({ ...settings, [field]: value })
|
||||
|
||||
const save = async () => {
|
||||
await api.updateSettings(settings)
|
||||
setSaved('Settings saved')
|
||||
setTimeout(() => setSaved(''), 2000)
|
||||
}
|
||||
|
||||
const test = async () => {
|
||||
setTestResult({ pending: true })
|
||||
await api.updateSettings(settings)
|
||||
const result = await api.testConnection()
|
||||
setTestResult(result)
|
||||
}
|
||||
|
||||
if (!settings) return null
|
||||
|
||||
return (
|
||||
<div className="page" style={{ maxWidth: 640 }}>
|
||||
<div className="page-header">
|
||||
<h1>Settings</h1>
|
||||
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{saved}</span>
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">Endpoint URL (OpenAI-compatible)</span>
|
||||
<input type="text" value={settings.endpoint_url}
|
||||
placeholder="http://localhost:11434/v1"
|
||||
onChange={(e) => setField('endpoint_url', e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="label">API Key</span>
|
||||
<input type="password" value={settings.api_key}
|
||||
placeholder="Leave empty for local endpoints"
|
||||
onChange={(e) => setField('api_key', e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="label">Model</span>
|
||||
<input type="text" value={settings.model} placeholder="e.g. llama3.1"
|
||||
onChange={(e) => setField('model', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 14 }}>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Temperature</span>
|
||||
<input type="number" step="0.1" min="0" max="2" value={settings.temperature}
|
||||
onChange={(e) => setField('temperature', Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Max output tokens</span>
|
||||
<input type="number" value={settings.max_output_tokens}
|
||||
onChange={(e) => setField('max_output_tokens', Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Context budget (tokens)</span>
|
||||
<input type="number" value={settings.context_token_budget}
|
||||
onChange={(e) => setField('context_token_budget', Number(e.target.value))} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span className="label">Reasoning budget (tokens)</span>
|
||||
<input type="number" min="0" value={settings.reasoning_max_tokens}
|
||||
onChange={(e) => setField('reasoning_max_tokens', Number(e.target.value))} />
|
||||
<span className="label" style={{ marginTop: 4 }}>
|
||||
For reasoning models: separate thinking budget on top of max output tokens,
|
||||
and thinking is shown collapsed above each response. 0 = off (nothing extra
|
||||
is sent — keep 0 for endpoints/models without reasoning support).
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">API mode</span>
|
||||
<select value={settings.api_mode} onChange={(e) => setField('api_mode', e.target.value)}>
|
||||
<option value="chat">Chat (/v1/chat/completions)</option>
|
||||
<option value="completion">Completion (/v1/completions)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="page-header" style={{ marginTop: 20 }}>
|
||||
<h3 style={{ margin: 0 }}>Memory system</h3>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 14 }}>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Summarization model</span>
|
||||
<input type="text" value={settings.summary_model}
|
||||
placeholder="Empty = use main model"
|
||||
onChange={(e) => setField('summary_model', e.target.value)} />
|
||||
</label>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Embedding model</span>
|
||||
<input type="text" value={settings.embedding_model}
|
||||
placeholder="e.g. nomic-embed-text (empty = memory bank off)"
|
||||
onChange={(e) => setField('embedding_model', e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 14 }}>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Memory bank capacity</span>
|
||||
<input type="number" min="1" value={settings.memory_bank_capacity}
|
||||
onChange={(e) => setField('memory_bank_capacity', Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
<span className="label">Memories per turn (top-K)</span>
|
||||
<input type="number" min="1" value={settings.memory_top_k}
|
||||
onChange={(e) => setField('memory_top_k', Number(e.target.value))} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">Narrator system prompt</span>
|
||||
<textarea rows={4} value={settings.narrator_prompt}
|
||||
onChange={(e) => setField('narrator_prompt', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>
|
||||
<button className="primary" onClick={save}>Save</button>
|
||||
<button onClick={test}>Test connection</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div style={{ marginTop: 16, color: testResult.ok ? 'var(--accent)' : 'var(--danger)' }}>
|
||||
{testResult.pending ? 'Testing…'
|
||||
: testResult.ok
|
||||
? `Connected. Models: ${testResult.models?.slice(0, 8).join(', ') || '(none listed)'}`
|
||||
: testResult.detail}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DebugLog />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user