Initial commit.

Basic docker deployment with Local LLM integration and simple game state.
This commit is contained in:
Aodhan Collins
2025-08-17 19:31:33 +01:00
commit 912b205699
30 changed files with 2476 additions and 0 deletions

255
web/app.py Normal file
View File

@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""
FastAPI web frontend for the text-based LLM interaction system.
Serves a simple web UI and exposes an API to interact with the LLM.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional, Tuple
from uuid import uuid4
from threading import Lock
from fastapi import FastAPI, Request, Response, Cookie, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from config import Config
from llm_client import LLMClient
from conversation import ConversationManager
from game_config import load_game_config
from game_state import GameState
# Application setup
app = FastAPI(title="Text Adventure - Web UI")
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
STATIC_DIR.mkdir(parents=True, exist_ok=True)
SESSIONS_DIR = (BASE_DIR.parent / "data" / "sessions")
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
# Mount /static for assets (requires 'aiofiles' in env for async file serving)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# Models
class ChatRequest(BaseModel):
message: str
# Globals
CONFIG = Config()
CLIENT = LLMClient(CONFIG)
GAMECFG = load_game_config()
_SESSIONS: dict[str, ConversationManager] = {}
_GAME_STATES: dict[str, GameState] = {}
_SESSIONS_LOCK = Lock()
SESSION_COOKIE_NAME = "session_id"
def _get_or_create_session(session_id: Optional[str]) -> Tuple[str, ConversationManager, bool]:
"""
Return an existing conversation manager by session_id,
or create a new session if not present.
Returns (session_id, manager, created_flag).
"""
created = False
with _SESSIONS_LOCK:
if not session_id or session_id not in _SESSIONS:
session_id = uuid4().hex
cm = ConversationManager()
# Seed with system prompt if configured
if getattr(GAMECFG, "system_prompt", None):
cm.add_system_message(GAMECFG.system_prompt)
_SESSIONS[session_id] = cm
created = True
manager = _SESSIONS[session_id]
return session_id, manager, created
def _ensure_state(session_id: str) -> GameState:
"""
Ensure a GameState exists for a given session and return it.
Loads from disk if present, otherwise creates a new state and persists it.
"""
with _SESSIONS_LOCK:
if session_id not in _GAME_STATES:
path = SESSIONS_DIR / f"{session_id}.json"
gs = None
if path.exists():
try:
with path.open("r", encoding="utf-8") as f:
data = json.load(f) or {}
gs = GameState()
for field in [
"room_description",
"door_description",
"door_locked",
"door_open",
"door_id",
"lock_id",
"lock_key_id",
"key_description",
"key_hidden",
"key_revealed",
"key_taken",
"key_id",
"container_id",
"inventory",
"exits",
"completed",
]:
if field in data:
setattr(gs, field, data[field])
except Exception:
gs = None
if gs is None:
gs = GameState.from_files("state")
try:
with path.open("w", encoding="utf-8") as f:
json.dump(gs.__dict__, f)
except Exception:
pass
_GAME_STATES[session_id] = gs
return _GAME_STATES[session_id]
# Routes
@app.get("/", response_class=FileResponse)
def index() -> FileResponse:
"""
Serve the main web UI.
"""
index_path = STATIC_DIR / "index.html"
if not index_path.exists():
# Provide a minimal fallback page if index.html is missing
# This should not happen in normal usage.
fallback = """<!doctype html>
<html>
<head><meta charset="utf-8"><title>Text Adventure</title></head>
<body><h1>Text Adventure Web UI</h1><p>index.html is missing.</p></body>
</html>
"""
tmp_path = STATIC_DIR / "_fallback_index.html"
tmp_path.write_text(fallback, encoding="utf-8")
return FileResponse(str(tmp_path))
return FileResponse(str(index_path))
@app.get("/api/health")
def health() -> dict:
"""
Health check endpoint.
"""
return {"status": "ok"}
@app.get("/api/session")
def session_info(
response: Response,
session_id: Optional[str] = Cookie(default=None, alias=SESSION_COOKIE_NAME),
) -> JSONResponse:
"""
Ensure a session exists, set cookie if needed, and return scenario metadata and start message.
Also returns the current public game state snapshot.
"""
sid, conv, created = _get_or_create_session(session_id)
if created:
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=sid,
httponly=True,
samesite="lax",
max_age=7 * 24 * 3600,
path="/",
)
gs = _ensure_state(sid)
payload = {
"session_id": sid,
"created": created,
"start_message": getattr(GAMECFG, "start_message", "") or "",
"scenario": getattr(GAMECFG, "scenario", {}) or {},
"rules": getattr(GAMECFG, "rules", []) or [],
"state": gs.to_public_dict(),
}
return JSONResponse(payload)
@app.post("/api/chat")
def chat(
req: ChatRequest,
response: Response,
session_id: Optional[str] = Cookie(default=None, alias=SESSION_COOKIE_NAME),
) -> JSONResponse:
"""
Accept a user message, apply deterministic game logic to update state,
then ask the LLM to narrate the outcome. Maintains a server-side session.
"""
message = req.message.strip()
if not message:
raise HTTPException(status_code=400, detail="Message cannot be empty")
sid, conv, created = _get_or_create_session(session_id)
# Set session cookie if new
if created:
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=sid,
httponly=True,
samesite="lax",
max_age=7 * 24 * 3600, # 7 days
path="/",
)
# Determine outcome via the game engine, then request narration
try:
gs = _ensure_state(sid)
conv.add_user_message(message)
engine_outcome = gs.apply_action(message) # {"events": [...]}
# Build a transient system message with canonical facts for the narrator
narrator_directive = {
"ENGINE_OUTCOME": {
"events": engine_outcome.get("events", []),
"state": gs.to_public_dict(),
},
"NARRATION_RULES": [
"Narrate strictly according to ENGINE_OUTCOME. Do not invent state.",
"Do not add items, unlock objects, or change inventory; the engine already did that.",
"Use 2-5 sentences, present tense, second person. Be concise and vivid.",
"If the action was impossible, explain why using the facts provided.",
],
}
transient_system = {
"role": "system",
"content": "ENGINE CONTEXT (JSON): " + json.dumps(narrator_directive),
}
messages = list(conv.get_history()) + [transient_system]
reply = CLIENT.get_response(messages)
conv.add_assistant_message(reply)
# Persist updated state
try:
with (SESSIONS_DIR / f"{sid}.json").open("w", encoding="utf-8") as f:
json.dump(gs.__dict__, f)
except Exception:
pass
return JSONResponse({
"reply": reply,
"completed": gs.completed,
"events": engine_outcome.get("events", []),
"state": gs.to_public_dict(),
})
except Exception as e:
# Do not leak internal details in production; log as needed.
raise HTTPException(status_code=502, detail=f"LLM backend error: {e}") from e

115
web/static/app.js Normal file
View File

@@ -0,0 +1,115 @@
(() => {
const el = {
statusDot: document.getElementById("statusDot"),
messages: document.getElementById("messages"),
form: document.getElementById("chatForm"),
input: document.getElementById("messageInput"),
sendBtn: document.getElementById("sendBtn"),
tplUser: document.getElementById("msg-user"),
tplAssistant: document.getElementById("msg-assistant"),
};
const state = {
sending: false,
};
function setStatus(ok) {
el.statusDot.classList.toggle("ok", !!ok);
el.statusDot.classList.toggle("err", !ok);
}
async function healthCheck() {
try {
const res = await fetch("/api/health", { cache: "no-store" });
setStatus(res.ok);
} catch {
setStatus(false);
}
}
function appendMessage(role, text) {
const tpl = role === "user" ? el.tplUser : el.tplAssistant;
const node = tpl.content.cloneNode(true);
const bubble = node.querySelector(".bubble");
bubble.textContent = text;
el.messages.appendChild(node);
el.messages.scrollTop = el.messages.scrollHeight;
}
function setSending(sending) {
state.sending = sending;
el.input.disabled = sending;
el.sendBtn.disabled = sending;
el.sendBtn.textContent = sending ? "Sending..." : "Send";
}
async function sendMessage(text) {
setSending(true);
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ message: text }),
});
if (!res.ok) {
let detail = "";
try {
const data = await res.json();
detail = data.detail || res.statusText;
} catch {
detail = res.statusText;
}
throw new Error(detail || `HTTP ${res.status}`);
}
const data = await res.json();
appendMessage("assistant", data.reply ?? "");
setStatus(true);
} catch (err) {
appendMessage("assistant", `Error: ${err.message || err}`);
setStatus(false);
} finally {
setSending(false);
}
}
el.form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = (el.input.value || "").trim();
if (!text || state.sending) return;
appendMessage("user", text);
el.input.value = "";
await sendMessage(text);
});
// Submit on Enter, allow Shift+Enter for newline (if we switch to textarea later)
el.input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
el.form.requestSubmit();
}
});
// Initial status check
healthCheck();
// Initialize session and show start message if configured
(async function initSession() {
try {
const res = await fetch("/api/session", { credentials: "same-origin" });
if (res.ok) {
const data = await res.json();
if (data.start_message) {
appendMessage("assistant", data.start_message);
}
}
} catch (_) {
// no-op
}
})();
// Periodic health check
setInterval(healthCheck, 15000);
})();

46
web/static/index.html Normal file
View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Text Adventure - Web UI</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<header class="app-header">
<h1>Text Adventure</h1>
<div class="status" id="statusDot" title="Backend status"></div>
</header>
<main class="container">
<section id="chat" class="chat">
<div id="messages" class="messages" aria-live="polite"></div>
</section>
<form id="chatForm" class="input-row" autocomplete="off">
<input
id="messageInput"
type="text"
placeholder="Type your message..."
aria-label="Message"
required
/>
<button id="sendBtn" type="submit">Send</button>
</form>
</main>
<template id="msg-user">
<div class="msg msg-user">
<div class="bubble"></div>
</div>
</template>
<template id="msg-assistant">
<div class="msg msg-assistant">
<div class="bubble"></div>
</div>
</template>
<script src="/static/app.js" defer></script>
</body>
</html>

175
web/static/styles.css Normal file
View File

@@ -0,0 +1,175 @@
:root {
--bg: #0e1116;
--panel: #161b22;
--muted: #8b949e;
--text: #e6edf3;
--accent: #2f81f7;
--accent-2: #3fb950;
--danger: #f85149;
--bubble-user: #1f6feb22;
--bubble-assistant: #30363d;
--radius: 10px;
--shadow: 0 8px 24px rgba(0,0,0,0.25);
}
* { box-sizing: border-box; }
html, body {
height: 100%;
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji";
background: linear-gradient(180deg, #0e1116 0%, #0b0e13 100%);
color: var(--text);
display: flex;
flex-direction: column;
}
.app-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
background: rgba(22,27,34,0.8);
backdrop-filter: blur(6px);
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid #21262d;
}
.app-header h1 {
font-size: 16px;
margin: 0;
letter-spacing: 0.3px;
color: var(--text);
font-weight: 600;
}
.status {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--muted);
box-shadow: 0 0 0 1px #21262d inset, 0 0 8px rgba(0,0,0,0.35);
}
.status.ok { background: var(--accent-2); box-shadow: 0 0 0 1px #2e7d32 inset, 0 0 16px rgba(63,185,80,0.6); }
.status.err { background: var(--danger); box-shadow: 0 0 0 1px #7f1d1d inset, 0 0 16px rgba(248,81,73,0.6); }
.container {
width: 100%;
max-width: 900px;
margin: 18px auto 24px;
padding: 0 14px;
display: flex;
flex-direction: column;
gap: 12px;
flex: 1 1 auto;
}
.chat {
background: var(--panel);
border: 1px solid #21262d;
border-radius: var(--radius);
min-height: 420px;
max-height: calc(100vh - 230px);
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: var(--shadow);
}
.messages {
flex: 1 1 auto;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.msg {
display: flex;
align-items: flex-start;
}
.msg-user { justify-content: flex-end; }
.msg-assistant { justify-content: flex-start; }
.msg .bubble {
max-width: 78%;
padding: 10px 12px;
line-height: 1.35;
border-radius: 14px;
font-size: 14px;
border: 1px solid #30363d;
word-wrap: break-word;
word-break: break-word;
white-space: pre-wrap;
}
.msg-user .bubble {
background: var(--bubble-user);
border-color: #1f6feb55;
color: var(--text);
}
.msg-assistant .bubble {
background: var(--bubble-assistant);
border-color: #30363d;
color: var(--text);
}
.input-row {
display: flex;
gap: 10px;
background: var(--panel);
border: 1px solid #21262d;
border-radius: var(--radius);
padding: 10px;
box-shadow: var(--shadow);
}
.input-row input[type="text"] {
flex: 1 1 auto;
background: #0d1117;
color: var(--text);
border: 1px solid #30363d;
border-radius: 8px;
padding: 12px 12px;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.input-row input[type="text"]:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(47,129,247,0.25);
}
.input-row button {
flex: 0 0 auto;
background: linear-gradient(180deg, #238636 0%, #2ea043 100%);
color: #fff;
border: 1px solid #2ea043;
border-radius: 8px;
padding: 0 16px;
font-weight: 600;
cursor: pointer;
min-width: 92px;
transition: transform 0.05s ease-in-out, filter 0.2s ease;
}
.input-row button:hover { filter: brightness(1.05); }
.input-row button:active { transform: translateY(1px); }
.input-row button:disabled {
cursor: not-allowed;
opacity: 0.7;
filter: grayscale(0.2);
}
@media (max-width: 640px) {
.messages { padding: 12px; }
.msg .bubble { max-width: 90%; }
}