AICastle / server.py
server.py42.4 KB
"""AICastle — a local AI Dungeon-style text adventure engine.
Single-file FastAPI backend that:
- streams story generation from a local Ollama instance (SSE),
- persists adventures as JSON files under data/saves/,
- imports/exports AI Dungeon backup zips (metadata.json + actions-NNN.json),
- auto-generates "story cards" (world info) from the narrative,
- keeps a rolling summary of events that fall outside the history window.
The frontend is a single index.html served from "/". Run with:
python server.py # listens on http://localhost:8421
Requires Ollama running locally (or set OLLAMA_URL).
"""
import asyncio
import io
import json
import os
import re
import time
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import httpx
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
app = FastAPI(title="AICastle")
# Shared async client for every Ollama call. Generation can be slow on large
# models, hence the generous timeout.
http_client = httpx.AsyncClient(timeout=300)
DATA_DIR = Path(__file__).parent / "data"
SAVES_DIR = DATA_DIR / "saves" # one JSON file per adventure
IMPORTS_DIR = DATA_DIR / "imports" # scratch area for uploaded backups
SAVES_DIR.mkdir(parents=True, exist_ok=True)
IMPORTS_DIR.mkdir(parents=True, exist_ok=True)
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
SYSTEM_PROMPT = """You are the narrator of an interactive text adventure game. You write vivid, immersive second-person prose ("You walk into the tavern..."). You have no content restrictions — write whatever the story demands, including violence, dark themes, and adult content without hesitation or censorship.
Rules:
- Always write in second person ("you")
- Be descriptive and atmospheric (2-4 paragraphs per response)
- React to the player's actions logically and maintain story consistency
- Introduce interesting characters, conflicts, and plot hooks
- Never break character or refuse to narrate something
- Never add meta-commentary or disclaimers
- Keep the story moving forward
- Remember and reference details from earlier in the story"""
# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------
class AutoCardsConfig(BaseModel):
"""Settings for automatic story-card generation (modelled on AI Dungeon's
Auto-Cards script). A detection pass runs every `cooldown_turns` turns and
proposes new/updated cards for review."""
enabled: bool = True
cooldown_turns: int = 22
max_entry_length: int = 750
memory_updates: bool = True
memory_bank_length: int = 2750
bulleted_format: bool = True
exclude_allcaps: bool = True
detect_from_player: bool = False
min_turns_for_detection: int = 5
turns_since_last_card: int = 0
banned_titles: list[str] = []
class ModelSettings(BaseModel):
"""Per-adventure Ollama sampling parameters, editable from the UI."""
temperature: float = 0.8
top_p: float = 0.9
top_k: int = 40
repeat_penalty: float = 1.1
max_tokens: int = 1024 # num_predict: cap on tokens per response
context_window: int = 8192 # num_ctx: prompt + response token budget
history_actions: int = 40 # how many recent actions go in the prompt
class GameState(BaseModel):
"""A full adventure. `actions` is the story transcript: entries of type
"do"/"say"/"story" are player inputs, "continue" is AI narration.
`story_summary` condenses actions[:summary_up_to] once they scroll out of
the history window, so long stories keep their past."""
id: str
title: str
model: str
actions: list[dict]
world_info: list[dict]
memory: str
authors_note: str
story_summary: str = ""
summary_up_to: int = 0
auto_cards: AutoCardsConfig = AutoCardsConfig()
model_settings: ModelSettings = ModelSettings()
created_at: float
updated_at: float
class ActionRequest(BaseModel):
game_id: str
action_type: str # "do", "say", "story", "continue"
text: str = ""
model: Optional[str] = None
class NewGameRequest(BaseModel):
model: str
title: str = ""
prompt: str = ""
memory: str = ""
authors_note: str = ""
class EditRequest(BaseModel):
game_id: str
action_index: int
new_text: str
class WorldInfoEntry(BaseModel):
game_id: str
keys: str
entry: str
title: str = ""
class SettingsUpdate(BaseModel):
game_id: str
memory: Optional[str] = None
authors_note: Optional[str] = None
model: Optional[str] = None
title: Optional[str] = None
auto_cards: Optional[dict] = None
model_settings: Optional[dict] = None
class WorldInfoEdit(BaseModel):
game_id: str
index: int
keys: Optional[str] = None
entry: Optional[str] = None
title: Optional[str] = None
# ---------------------------------------------------------------------------
# Persistence — JSON files on disk with an in-memory cache in front
# ---------------------------------------------------------------------------
_game_cache: dict[str, GameState] = {}
def save_game(state: GameState):
"""Write the adventure to disk and refresh the cache entry."""
_game_cache[state.id] = state
path = SAVES_DIR / f"{state.id}.json"
path.write_text(json.dumps(state.model_dump(), separators=(",", ":")))
def load_game(game_id: str) -> GameState:
"""Return the cached adventure, falling back to the JSON file on disk."""
if game_id in _game_cache:
return _game_cache[game_id]
path = SAVES_DIR / f"{game_id}.json"
if not path.exists():
raise HTTPException(404, "Game not found")
state = GameState(**json.loads(path.read_text()))
_game_cache[game_id] = state
return state
# ---------------------------------------------------------------------------
# Prompt construction and Ollama access
# ---------------------------------------------------------------------------
def build_prompt(state: GameState, new_action: Optional[dict] = None) -> list[dict]:
"""Assemble the chat messages sent to the model.
The system message carries everything persistent: narrator instructions,
memory, ALL story cards (no keyword filtering — cards exist to always be
in context), the author's note, and the rolling summary. The last
`history_actions` transcript entries follow as user/assistant turns,
with player inputs prefixed the way AI Dungeon formats them.
"""
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
if state.memory:
messages[0]["content"] += f"\n\n[Memory — always remember this context]\n{state.memory}"
if state.world_info:
info_text = "\n".join(
f"[{wi.get('title', 'Info')}]: {wi.get('entry', '')}"
for wi in state.world_info
)
messages[0]["content"] += f"\n\n[World Information — use these details when relevant]\n{info_text}"
if state.authors_note:
messages[0]["content"] += f"\n\n[Author's Note — follow this guidance for style/tone]\n{state.authors_note}"
if state.story_summary:
messages[0]["content"] += f"\n\n[Story So Far — summary of earlier events]\n{state.story_summary}"
max_actions = state.model_settings.history_actions
recent_actions = state.actions[-max_actions:] if len(state.actions) > max_actions else state.actions
for action in recent_actions:
role = "assistant" if action["type"] == "continue" else "user"
text = action["text"]
if action["type"] == "do":
text = f"> You {text}"
elif action["type"] == "say":
text = f'> You say "{text}"'
elif action["type"] == "story":
text = f"> {text}"
messages.append({"role": role, "content": text})
if new_action:
text = new_action["text"]
if new_action["type"] == "do":
text = f"> You {text}"
elif new_action["type"] == "say":
text = f'> You say "{text}"'
elif new_action["type"] == "story":
text = f"> {text}"
if new_action["type"] != "continue":
messages.append({"role": "user", "content": text})
return messages
async def stream_ollama(model: str, messages: list[dict], options: Optional[dict] = None):
"""Yield response text chunks from Ollama's streaming /api/chat.
keep_alive keeps the model loaded in VRAM between turns."""
body = {"model": model, "messages": messages, "stream": True, "keep_alive": "30m"}
if options:
body["options"] = options
async with http_client.stream("POST", f"{OLLAMA_URL}/api/chat", json=body) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if line:
chunk = json.loads(line)
content = chunk.get("message", {}).get("content", "")
if content:
yield content
if chunk.get("done"):
break
# ---------------------------------------------------------------------------
# Core routes: frontend, models, game CRUD
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def index():
"""Serve the single-page frontend."""
return Path(__file__).with_name("index.html").read_text()
@app.get("/api/models")
async def list_models():
"""List models installed in the local Ollama instance."""
try:
resp = await http_client.get(f"{OLLAMA_URL}/api/tags", timeout=10)
resp.raise_for_status()
models = resp.json().get("models", [])
return {"models": [{"name": m["name"], "size": m.get("size", 0)} for m in models]}
except Exception as e:
raise HTTPException(503, f"Cannot reach Ollama: {e}")
@app.get("/api/games")
async def list_games():
"""Sidebar listing: id/title/model/turn count, newest first."""
games = []
for f in sorted(SAVES_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
game_id = f.stem
state = load_game(game_id)
games.append({
"id": state.id,
"title": state.title,
"model": state.model,
"action_count": len(state.actions),
"updated_at": state.updated_at,
})
return {"games": games}
@app.post("/api/games/new")
async def new_game(req: NewGameRequest):
"""Create an adventure; if a starting prompt is given, generate the
opening narration synchronously before returning."""
game_id = str(uuid.uuid4())[:8]
state = GameState(
id=game_id,
title=req.title or "Untitled Adventure",
model=req.model,
actions=[],
world_info=[],
memory=req.memory,
authors_note=req.authors_note,
created_at=time.time(),
updated_at=time.time(),
)
if req.prompt:
messages = build_prompt(state)
messages.append({"role": "user", "content": f"Begin the story with this premise: {req.prompt}"})
full_response = ""
async for chunk in stream_ollama(req.model, messages):
full_response += chunk
state.actions.append({"type": "continue", "text": full_response, "timestamp": time.time()})
save_game(state)
return {"game": state.model_dump()}
# ---------------------------------------------------------------------------
# Story actions (SSE streaming)
# ---------------------------------------------------------------------------
@app.post("/api/action")
async def perform_action(req: ActionRequest):
"""Record a player action and stream the AI's narration back as
Server-Sent Events: {chunk} while generating, then {done} on success or
{error} on failure. Responses shorter than `min_len` chars are retried
once (small models occasionally emit a single token)."""
state = load_game(req.game_id)
model = req.model or state.model
if req.model:
state.model = model
new_action = {"type": req.action_type, "text": req.text, "timestamp": time.time()}
if req.action_type != "continue":
state.actions.append(new_action)
messages = build_prompt(state, new_action if req.action_type == "continue" else None)
opts = {
"temperature": state.model_settings.temperature,
"top_p": state.model_settings.top_p,
"top_k": state.model_settings.top_k,
"repeat_penalty": state.model_settings.repeat_penalty,
"num_predict": state.model_settings.max_tokens,
"num_ctx": state.model_settings.context_window,
}
min_len = 20
async def generate():
for attempt in range(2):
full_response = ""
try:
async for chunk in stream_ollama(model, messages, opts):
full_response += chunk
yield f"data: {json.dumps({'chunk': chunk})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return
if len(full_response.strip()) >= min_len:
break
if attempt == 0:
yield f"data: {json.dumps({'retry': 'Response too short, retrying...'})}\n\n"
full_response = ""
if not full_response.strip():
yield f"data: {json.dumps({'error': 'Model returned an empty response. Try again or switch models.'})}\n\n"
return
if len(full_response.strip()) < min_len:
yield f"data: {json.dumps({'error': f'Model gave a very short response: \"{full_response.strip()}\". Try again or switch models.'})}\n\n"
return
state.actions.append({"type": "continue", "text": full_response, "timestamp": time.time()})
state.updated_at = time.time()
save_game(state)
yield f"data: {json.dumps({'done': True, 'action_index': len(state.actions) - 1})}\n\n"
asyncio.create_task(_bg_summarize(state))
return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/api/action/retry")
async def retry_action(req: ActionRequest):
"""Discard the last AI response and regenerate it. Same SSE protocol and
short-response retry as /api/action."""
state = load_game(req.game_id)
model = req.model or state.model
if state.actions and state.actions[-1]["type"] == "continue":
state.actions.pop()
messages = build_prompt(state)
opts = {
"temperature": state.model_settings.temperature,
"top_p": state.model_settings.top_p,
"top_k": state.model_settings.top_k,
"repeat_penalty": state.model_settings.repeat_penalty,
"num_predict": state.model_settings.max_tokens,
"num_ctx": state.model_settings.context_window,
}
min_len = 20
async def generate():
for attempt in range(2):
full_response = ""
try:
async for chunk in stream_ollama(model, messages, opts):
full_response += chunk
yield f"data: {json.dumps({'chunk': chunk})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return
if len(full_response.strip()) >= min_len:
break
if attempt == 0:
yield f"data: {json.dumps({'retry': 'Response too short, retrying...'})}\n\n"
full_response = ""
if not full_response.strip():
yield f"data: {json.dumps({'error': 'Model returned an empty response. Try again or switch models.'})}\n\n"
return
if len(full_response.strip()) < min_len:
yield f"data: {json.dumps({'error': f'Model gave a very short response: \"{full_response.strip()}\". Try again or switch models.'})}\n\n"
return
state.actions.append({"type": "continue", "text": full_response, "timestamp": time.time()})
state.updated_at = time.time()
save_game(state)
yield f"data: {json.dumps({'done': True})}\n\n"
asyncio.create_task(_bg_summarize(state))
return StreamingResponse(generate(), media_type="text/event-stream")
async def _bg_summarize(state: GameState):
"""Fire-and-forget wrapper so summarization never blocks or breaks the
SSE stream that scheduled it."""
try:
await update_story_summary(state)
save_game(state)
except Exception:
pass
@app.post("/api/action/undo")
async def undo_action(req: ActionRequest):
"""Remove the last transcript entry. Because AI narration is always the
last entry of a turn, one undo removes the response and a second undo
removes the player input that caused it."""
state = load_game(req.game_id)
if state.actions:
state.actions.pop()
state.updated_at = time.time()
save_game(state)
return {"actions": state.actions}
@app.post("/api/action/edit")
async def edit_action(req: EditRequest):
"""Replace the text of any transcript entry (the hover 'edit' button)."""
state = load_game(req.game_id)
if 0 <= req.action_index < len(state.actions):
state.actions[req.action_index]["text"] = req.new_text
state.updated_at = time.time()
save_game(state)
return {"actions": state.actions}
# ---------------------------------------------------------------------------
# Settings and world info (story cards)
# ---------------------------------------------------------------------------
@app.post("/api/settings")
async def update_settings(req: SettingsUpdate):
"""Partial update: only the fields present in the request are changed.
auto_cards / model_settings dicts are merged over the stored values."""
state = load_game(req.game_id)
if req.memory is not None:
state.memory = req.memory
if req.authors_note is not None:
state.authors_note = req.authors_note
if req.model is not None:
state.model = req.model
if req.title is not None:
state.title = req.title
if req.auto_cards is not None:
state.auto_cards = AutoCardsConfig(**{**state.auto_cards.model_dump(), **req.auto_cards})
if req.model_settings is not None:
state.model_settings = ModelSettings(**{**state.model_settings.model_dump(), **req.model_settings})
state.updated_at = time.time()
save_game(state)
return {"ok": True}
@app.post("/api/world-info/edit")
async def edit_world_info(req: WorldInfoEdit):
state = load_game(req.game_id)
if req.index < 0 or req.index >= len(state.world_info):
raise HTTPException(400, "Invalid index")
if req.title is not None:
state.world_info[req.index]["title"] = req.title
if req.keys is not None:
state.world_info[req.index]["keys"] = req.keys
if req.entry is not None:
state.world_info[req.index]["entry"] = req.entry
state.updated_at = time.time()
save_game(state)
return {"world_info": state.world_info}
@app.post("/api/world-info/add")
async def add_world_info(entry: WorldInfoEntry):
state = load_game(entry.game_id)
state.world_info.append({
"keys": entry.keys,
"entry": entry.entry,
"title": entry.title,
})
state.updated_at = time.time()
save_game(state)
return {"world_info": state.world_info}
@app.post("/api/world-info/remove")
async def remove_world_info(game_id: str = Form(...), index: int = Form(...)):
state = load_game(game_id)
if 0 <= index < len(state.world_info):
state.world_info.pop(index)
state.updated_at = time.time()
save_game(state)
return {"world_info": state.world_info}
# ---------------------------------------------------------------------------
# LLM prompt templates (auto-cards + rolling summary)
# ---------------------------------------------------------------------------
AUTOCARDS_DETECT_PROMPT = """Analyze the following story text and list any NEW named characters, locations, factions, or important items that appear. Only list names that are proper nouns (capitalized names). Return ONLY a JSON array of strings with the names, nothing else. If none found, return [].
Story text:
{text}"""
AUTOCARDS_GENERATE_PROMPT = """Write a brief and coherent informational entry for {title} following these instructions:
- Write only third-person pure prose information about {title} using complete sentences with correct punctuation
- Avoid short-term temporary details or appearances, instead focus on plot-significant information
- Prioritize story-relevant details about {title} first to ensure seamless integration with the previous plot
- Create new information based on the context and story direction
- Mention {title} in every sentence
- Be concise and grounded
- Imitate the story's writing style
{memory_section}{cards_section}
Story context:
{context}"""
AUTOCARDS_UPDATE_PROMPT = """Update the existing informational entry for {title} with new information from recent story events:
- Preserve important existing information that is still relevant
- Add new details from recent events
- Remove or correct information that has been contradicted by the story
- Write only third-person pure prose information about {title}
- Mention {title} in every sentence
- Be concise and grounded
- Imitate the story's writing style
{memory_section}{cards_section}
Existing entry for {title}:
{existing_entry}
Recent story context:
{context}"""
STORY_SUMMARY_PROMPT = """You are summarizing the events of an interactive story so far. The summary will be used as context for future story generation.
Rules:
- Write in past tense, second person ("You did X, then Y happened")
- Preserve all important plot points, character introductions, relationships, decisions, and consequences
- Keep the narrative flow — this should read as a condensed retelling, not bullet points
- Prioritize recent events over older ones if space is tight
- Include character names, locations, and key items
- Do not add anything that didn't happen in the story
{previous_summary}
New events to incorporate:
{new_events}
Write a cohesive summary of the entire story so far (previous summary + new events combined), in roughly 500-800 words:"""
AUTOCARDS_MEMORY_PROMPT = """Summarize and condense the given paragraph into a narrow and focused memory passage:
- Ensure the passage retains the core meaning and most essential details
- Use the third-person perspective
- Prioritize information-density, accuracy, and completeness
- Remain brief and concise
- Write firmly in the past tense
- Only reference information present inside the paragraph itself
Paragraph about {title}:
\"\"\"{memory}\"\"\""""
def build_autocards_context(state: GameState, title: str) -> dict:
"""Context bundle for card generation: recent story text, memory,
author's note, and every other card (the card being written is excluded
so the model doesn't copy itself)."""
context = "\n".join(a["text"] for a in state.actions[-15:])[-4000:]
memory_section = ""
if state.memory:
memory_section = f"\n\nMemory:\n{state.memory}"
if state.authors_note:
memory_section += f"\n\nAuthor's Note:\n{state.authors_note}"
relevant_cards = []
for wi in state.world_info:
if wi.get("title", "").lower() == title.lower():
continue
relevant_cards.append(f"[{wi.get('title', 'Info')}]: {wi.get('entry', '')}")
cards_section = ""
if relevant_cards:
cards_section = "\n\nExisting world info:\n" + "\n".join(relevant_cards)
return {"context": context, "memory_section": memory_section, "cards_section": cards_section}
async def call_ollama(model: str, prompt: str) -> str:
"""One-shot (non-streaming) completion, used for card generation and
summaries where we only need the final text."""
resp = await http_client.post(
f"{OLLAMA_URL}/api/generate",
json={"model": model, "prompt": prompt, "stream": False, "keep_alive": "30m"},
timeout=120,
)
resp.raise_for_status()
return resp.json().get("response", "")
async def update_story_summary(state: GameState):
"""Fold actions that have scrolled out of the history window into
`story_summary`. Runs in the background after each turn; batches at
least 10 actions per pass to avoid summarizing one line at a time.
The previous summary is fed back in so the result stays cumulative."""
max_actions = state.model_settings.history_actions
if len(state.actions) <= max_actions:
return
cutoff = len(state.actions) - max_actions
if cutoff <= state.summary_up_to:
return
unsummarized = state.actions[state.summary_up_to:cutoff]
if len(unsummarized) < 10:
return
events_text = ""
for a in unsummarized:
if a["type"] == "do":
events_text += f"> You {a['text']}\n"
elif a["type"] == "say":
events_text += f'> You say "{a["text"]}"\n'
elif a["type"] == "story":
events_text += f"> {a['text']}\n"
else:
events_text += a["text"] + "\n"
previous = ""
if state.story_summary:
previous = f"Previous summary:\n{state.story_summary}\n\n"
prompt = STORY_SUMMARY_PROMPT.format(
previous_summary=previous,
new_events=events_text[-6000:],
)
summary = await call_ollama(state.model, prompt)
state.story_summary = summary.strip()
state.summary_up_to = cutoff
# ---------------------------------------------------------------------------
# Auto-cards: detect entities → generate previews → user accepts/rejects
# ---------------------------------------------------------------------------
@app.post("/api/auto-cards/detect")
async def auto_cards_detect(req: ActionRequest):
"""Scan recent narration for named entities worth a story card.
Respects the cooldown counter; returns both brand-new titles and titles
matching existing cards (those become updates)."""
state = load_game(req.game_id)
if not state.auto_cards.enabled:
return {"titles": [], "skipped": "auto-cards disabled"}
state.auto_cards.turns_since_last_card += 1
if state.auto_cards.turns_since_last_card < state.auto_cards.cooldown_turns:
save_game(state)
return {"titles": [], "skipped": "cooldown", "turns_remaining": state.auto_cards.cooldown_turns - state.auto_cards.turns_since_last_card}
recent_actions = state.actions[-10:]
text_to_scan = "\n".join(
a["text"] for a in recent_actions
if a["type"] == "continue" or (state.auto_cards.detect_from_player and a["type"] in ("do", "say", "story"))
)
if not text_to_scan.strip():
return {"titles": []}
model = req.model or state.model
raw = await call_ollama(model, AUTOCARDS_DETECT_PROMPT.format(text=text_to_scan[-3000:]))
try:
match = re.search(r'\[.*?\]', raw, re.DOTALL)
titles = json.loads(match.group()) if match else []
except (json.JSONDecodeError, AttributeError):
titles = []
existing_titles_map = {wi.get("title", "").lower(): wi.get("title", "") for wi in state.world_info}
banned = {b.lower() for b in state.auto_cards.banned_titles}
new_titles = []
update_titles = []
for t in titles:
t_clean = t.strip()
if not t_clean:
continue
if t_clean.lower() in banned:
continue
if state.auto_cards.exclude_allcaps and t_clean == t_clean.upper() and len(t_clean) > 2:
continue
if t_clean.lower() in existing_titles_map:
update_titles.append(existing_titles_map[t_clean.lower()])
else:
new_titles.append(t_clean)
return {"titles": new_titles + update_titles, "updates": update_titles}
async def _preview_one_card(state: GameState, title: str, model: str) -> dict:
"""Generate (without saving) the proposed content for one card. If a card
with the same title exists, an update is produced instead of a new entry;
`is_update`/`existing_index` tell the frontend which one to replace."""
existing_wi = None
existing_index = -1
for i, wi in enumerate(state.world_info):
if wi.get("title", "").lower() == title.lower():
existing_wi = wi
existing_index = i
break
ctx = build_autocards_context(state, title)
if existing_wi:
entry = await call_ollama(model, AUTOCARDS_UPDATE_PROMPT.format(
title=title, existing_entry=existing_wi.get("entry", ""), **ctx))
else:
entry = await call_ollama(model, AUTOCARDS_GENERATE_PROMPT.format(title=title, **ctx))
entry = entry.strip()
if state.auto_cards.max_entry_length and len(entry) > state.auto_cards.max_entry_length:
entry = entry[:state.auto_cards.max_entry_length].rsplit(".", 1)[0] + "."
if state.auto_cards.bulleted_format:
sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', entry) if s.strip()]
entry = "\n".join(f"- {s}" for s in sentences)
keys = existing_wi.get("keys", title) if existing_wi else title
return {
"title": title, "keys": keys, "entry": entry,
"is_update": existing_wi is not None, "existing_index": existing_index,
}
@app.post("/api/auto-cards/preview")
async def auto_cards_preview(game_id: str = Form(...), title: str = Form(...), model: str = Form(None)):
state = load_game(game_id)
model = model or state.model
return await _preview_one_card(state, title, model)
class BatchPreviewRequest(BaseModel):
game_id: str
titles: list[str]
model: Optional[str] = None
@app.post("/api/auto-cards/preview-batch")
async def auto_cards_preview_batch(req: BatchPreviewRequest):
"""Generate previews for several titles in parallel — one Ollama call
each, gathered concurrently."""
state = load_game(req.game_id)
model = req.model or state.model
results = await asyncio.gather(*[
_preview_one_card(state, title, model) for title in req.titles
])
return {"cards": list(results)}
@app.post("/api/auto-cards/generate")
async def auto_cards_generate(game_id: str = Form(...), title: str = Form(...), model: str = Form(None)):
"""Generate a card and save it immediately (no review step). Kept for
the one-click path; the UI normally goes through preview + accept."""
state = load_game(game_id)
model = model or state.model
ctx = build_autocards_context(state, title)
entry = await call_ollama(model, AUTOCARDS_GENERATE_PROMPT.format(title=title, **ctx))
entry = entry.strip()
if state.auto_cards.max_entry_length and len(entry) > state.auto_cards.max_entry_length:
entry = entry[:state.auto_cards.max_entry_length].rsplit(".", 1)[0] + "."
if state.auto_cards.bulleted_format:
sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', entry) if s.strip()]
entry = "\n".join(f"- {s}" for s in sentences)
keys = title
state.world_info.append({"keys": keys, "entry": entry, "title": title})
state.auto_cards.turns_since_last_card = 0
state.updated_at = time.time()
save_game(state)
return {"world_info": state.world_info, "generated": {"title": title, "entry": entry}}
@app.post("/api/auto-cards/update-memory")
async def auto_cards_update_memory(game_id: str = Form(...), wi_index: int = Form(...), model: str = Form(None)):
"""Compress a card's memory bank once it outgrows memory_bank_length."""
state = load_game(game_id)
if wi_index < 0 or wi_index >= len(state.world_info):
raise HTTPException(400, "Invalid world info index")
model = model or state.model
wi = state.world_info[wi_index]
desc = wi.get("description", "")
if not desc:
return {"ok": True, "skipped": "no memories to compress"}
max_len = state.auto_cards.memory_bank_length
if len(desc) <= max_len:
return {"ok": True, "skipped": "memory within limit"}
summary = await call_ollama(model, AUTOCARDS_MEMORY_PROMPT.format(title=wi["title"], memory=desc))
state.world_info[wi_index]["description"] = summary.strip()
state.updated_at = time.time()
save_game(state)
return {"ok": True, "summary": summary.strip()}
# ---------------------------------------------------------------------------
# Import / export — AI Dungeon backup zip format
# (metadata.json with adventure info + story cards, actions-NNN.json parts)
# ---------------------------------------------------------------------------
@app.get("/api/game/{game_id}")
async def get_game(game_id: str):
state = load_game(game_id)
return {"game": state.model_dump()}
@app.get("/api/game/{game_id}/export")
async def export_game(game_id: str):
"""Build an AI Dungeon-compatible backup zip. Player actions get their
"> You ..." prefixes re-applied and memory its {{...}} wrapper, so the
file round-trips through AI Dungeon's own importer."""
state = load_game(game_id)
actions_per_part = 1000 # AI Dungeon splits the transcript into parts of 1000
raw_actions = []
for a in state.actions:
exported = {"id": str(len(raw_actions)), "type": a["type"], "text": a["text"]}
if a["type"] == "say":
exported["text"] = f'\n> You say "{a["text"]}"\n'
elif a["type"] == "do":
exported["text"] = f"\n> You {a['text']}\n"
ts = a.get("timestamp")
if ts:
iso = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
exported["createdAt"] = iso
exported["updatedAt"] = iso
raw_actions.append(exported)
total_parts = max(1, (len(raw_actions) + actions_per_part - 1) // actions_per_part)
memory_text = state.memory
if memory_text and not memory_text.startswith("{{"):
memory_text = "{{" + memory_text + "}}"
story_cards = []
for wi in state.world_info:
value = wi.get("entry", "")
if wi.get("title"):
value = "{title: " + wi["title"] + "}\n" + value
keys = wi.get("keys", "")
story_cards.append({
"id": str(hash(keys) & 0xFFFFFFFF),
"createdAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
"updatedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
"keys": keys,
"value": value,
})
metadata = {
"exportedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
"adventure": {
"id": state.id,
"publicId": str(uuid.uuid4()),
"title": state.title,
"description": state.actions[0]["text"][:200] if state.actions else "",
"memory": memory_text,
"authorsNote": state.authors_note,
"tags": [],
"thirdPerson": False,
"published": False,
"unlisted": False,
"createdAt": datetime.fromtimestamp(state.created_at, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
"updatedAt": datetime.fromtimestamp(state.updated_at, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
},
"state": {
"type": "adventure",
"storySummary": "",
"storyCardInstructions": "",
"storyCardStoryInformation": "",
"instructions": {"type": "scenario", "scenario": ""},
"storyCards": story_cards,
"memories": [],
},
"totalActionCount": len(raw_actions),
"totalParts": total_parts,
}
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("metadata.json", json.dumps(metadata, ensure_ascii=False, indent=2))
for i in range(total_parts):
start = i * actions_per_part
end = start + actions_per_part
part = {
"partNumber": i + 1,
"totalParts": total_parts,
"actions": raw_actions[start:end],
}
zf.writestr(f"actions-{i+1:03d}.json", json.dumps(part, ensure_ascii=False, indent=2))
buf.seek(0)
safe_title = re.sub(r'[^\x20-\x7E]', '', state.title)
safe_title = re.sub(r'[^\w\s-]', '', safe_title).strip().replace(' ', '-')[:50] or "adventure"
filename = f"{safe_title}-backup.zip"
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@app.delete("/api/game/{game_id}")
async def delete_game(game_id: str):
_game_cache.pop(game_id, None)
path = SAVES_DIR / f"{game_id}.json"
if path.exists():
path.unlink()
return {"ok": True}
def parse_aidungeon_action_text(action: dict) -> dict:
"""Convert one AI Dungeon action into our transcript format. AI Dungeon
stores say/do inputs pre-formatted ('> You say "..."'); strip those
markers since build_prompt re-adds them at generation time."""
atype = action.get("type", "continue")
text = action.get("text", "")
ts = action.get("createdAt", "")
timestamp = time.time()
if ts:
try:
timestamp = datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
except ValueError:
pass
if atype == "say":
m = re.match(r'^\s*>\s*You say\s*["""](.+?)["""]?\s*$', text.strip())
if m:
text = m.group(1)
elif atype == "do":
m = re.match(r'^\s*>\s*You\s+(.+)$', text.strip(), re.DOTALL)
if m:
text = m.group(1).strip()
elif atype == "start":
atype = "continue"
return {"type": atype, "text": text.strip() if atype in ("say", "do") else text, "timestamp": timestamp}
def parse_story_cards(cards: list) -> list[dict]:
"""Parse AI Dungeon story cards into world info entries.
Card values appear in three shapes in the wild:
1. "{title: Name}\\n<entry>" — explicit title prefix
2. "{<entry text>}" — whole entry wrapped in braces, no title
3. plain text — no braces at all
For 2 and 3 the first trigger key doubles as the title. The Auto-Cards
script's own config card is skipped."""
world_info = []
for card in cards:
if not isinstance(card, dict):
continue
keys = card.get("keys", "")
value = card.get("value", card.get("entry", ""))
if not keys or not value:
continue
combined = keys + " " + value
if "Auto-Cards" in combined or "auto-cards" in combined.lower():
continue
title = ""
title_match = re.match(r'^\{title:\s*(.+?)\}\s*\n?', value)
if title_match:
title = title_match.group(1).strip()
value = value[title_match.end():]
elif value.startswith("{") and "}" in value:
closing = value.index("}")
inner = value[1:closing]
value = inner + value[closing + 1:]
if not title:
title = keys.split(",")[0].strip()
world_info.append({"keys": keys, "entry": value.strip(), "title": title})
return world_info
@app.post("/api/import/aidungeon")
async def import_aidungeon(
files: list[UploadFile] = File(...),
model: str = Form("gemma3:12b"),
):
"""Import an AI Dungeon backup: either the zip itself or its extracted
JSON files. Accepts multiple files; metadata provides title/memory/cards,
actions-NNN.json parts are stitched together in partNumber order."""
game_id = str(uuid.uuid4())[:8]
all_actions = []
all_world_info = []
title = ""
memory = ""
authors_note = ""
for file in files:
content = await file.read()
fname = file.filename or ""
if fname.endswith(".zip"):
with zipfile.ZipFile(io.BytesIO(content)) as zf:
names = [n for n in zf.namelist() if not n.startswith("__MACOSX")]
metadata = {}
action_parts = []
for name in names:
raw = zf.read(name).decode("utf-8", errors="replace")
try:
data = json.loads(raw)
except json.JSONDecodeError:
continue
if name == "metadata.json" or (isinstance(data, dict) and "adventure" in data):
metadata = data
elif isinstance(data, dict) and "actions" in data and "partNumber" in data:
action_parts.append(data)
if metadata:
adv = metadata.get("adventure", {})
if adv.get("title") and not title:
title = adv["title"]
if adv.get("memory"):
memory = re.sub(r'^\{\{|\}\}$', '', adv["memory"].strip())
if adv.get("authorsNote"):
authors_note = adv["authorsNote"]
state_data = metadata.get("state", {})
all_world_info.extend(parse_story_cards(state_data.get("storyCards", [])))
action_parts.sort(key=lambda p: p.get("partNumber", 0))
for part in action_parts:
for action in part.get("actions", []):
all_actions.append(parse_aidungeon_action_text(action))
elif fname.endswith(".json"):
try:
data = json.loads(content)
except json.JSONDecodeError:
continue
if isinstance(data, dict) and "adventure" in data:
adv = data.get("adventure", {})
if adv.get("title") and not title:
title = adv["title"]
if adv.get("memory"):
memory = re.sub(r'^\{\{|\}\}$', '', adv["memory"].strip())
if adv.get("authorsNote"):
authors_note = adv["authorsNote"]
state_data = data.get("state", {})
all_world_info.extend(parse_story_cards(state_data.get("storyCards", [])))
elif isinstance(data, dict) and "actions" in data and "partNumber" in data:
for action in data.get("actions", []):
all_actions.append(parse_aidungeon_action_text(action))
elif isinstance(data, list):
all_world_info.extend(parse_story_cards(data))
state = GameState(
id=game_id,
title=title or "Imported Adventure",
model=model,
actions=all_actions,
world_info=all_world_info,
memory=memory,
authors_note=authors_note,
created_at=time.time(),
updated_at=time.time(),
)
save_game(state)
return {
"game": state.model_dump(),
"imported": "aidungeon",
"action_count": len(all_actions),
"card_count": len(all_world_info),
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8421)
