Initial commit: AI Dungeon clone (FastAPI backend + React frontend)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFsGHju9szibJJa2YJcdbg
This commit is contained in:
@@ -0,0 +1,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()
|
||||
Reference in New Issue
Block a user