Add rulebook RAG pipeline and LLM-driven game setup wizard

RAG: switch Postgres to pgvector, chunk and embed the three D&D
rulebooks locally via sentence-transformers, and retrieve relevant
excerpts per DM turn (query = latest player message) to ground the
system prompt. Retrieval runs off the event loop and is capped by a
relevance threshold and a max character budget so it can't blow up
context size or cost.

Game setup wizard: creating a game now opens a short chat where the
DM asks about genre, length, and the player's experience level, then
proposes a name and description via a tool call. The player can edit
both before creating the game. Stateless endpoint — the frontend
carries the conversation, no DB needed since the game doesn't exist
yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-08-31 20:03:05 +02:00
parent f37dc9fa76
commit 419f5e3a89
26 changed files with 82471 additions and 51 deletions
+134
View File
@@ -0,0 +1,134 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { createGame, Game, GameSetupProposal, runGameSetupChat } from "../api/games";
import SendMessageForm from "./SendMessageForm";
interface DisplayTurn {
role: "assistant" | "user";
content: string;
}
export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) => void }) {
const [rawMessages, setRawMessages] = useState<Record<string, unknown>[]>([]);
const [turns, setTurns] = useState<DisplayTurn[]>([]);
const [proposal, setProposal] = useState<GameSetupProposal | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
function applyResponse(res: {
messages: Record<string, unknown>[];
assistant_text: string | null;
proposal: GameSetupProposal | null;
}) {
setRawMessages(res.messages);
if (res.assistant_text) {
setTurns((prev) => [...prev, { role: "assistant", content: res.assistant_text! }]);
}
if (res.proposal) {
setProposal(res.proposal);
setName(res.proposal.name);
setDescription(res.proposal.description);
if (!res.assistant_text) {
setTurns((prev) => [
...prev,
{ role: "assistant", content: "Hier ist mein Vorschlag für dein Spiel — pass unten gern noch an." },
]);
}
}
}
useEffect(() => {
const controller = new AbortController();
(async () => {
setLoading(true);
try {
const res = await runGameSetupChat([], controller.signal);
applyResponse(res);
} catch (err) {
if (!(err instanceof DOMException && err.name === "AbortError")) throw err;
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => {
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
}, [turns]);
async function handleSend(content: string) {
setTurns((prev) => [...prev, { role: "user", content }]);
const nextMessages = [...rawMessages, { role: "user", content }];
applyResponse(await runGameSetupChat(nextMessages));
}
async function handleCreate(e: FormEvent) {
e.preventDefault();
setCreating(true);
setError(null);
try {
const game = await createGame(name, description);
onCreated(game);
} catch {
setError("Spiel konnte nicht erstellt werden.");
setCreating(false);
}
}
return (
<div className="mb-6 rounded-lg border border-slate-800 bg-slate-900">
<div className="flex h-64 flex-col gap-2 overflow-y-auto p-4">
{turns.map((turn, i) => (
<div key={i} className={turn.role === "assistant" ? "text-slate-100" : "text-slate-300"}>
<span
className={
turn.role === "assistant" ? "font-semibold text-amber-400" : "font-semibold text-sky-400"
}
>
{turn.role === "assistant" ? "Dungeon Master" : "Du"}:
</span>{" "}
<span className="whitespace-pre-wrap">{turn.content}</span>
</div>
))}
{loading && <p className="text-sm text-slate-500">Der Dungeon Master überlegt…</p>}
<div ref={bottomRef} />
</div>
<SendMessageForm onSend={handleSend} disabled={loading} placeholder="Deine Antwort..." />
{proposal && (
<form onSubmit={handleCreate} className="flex flex-col gap-3 border-t border-slate-800 p-4">
<input
required
placeholder="Name des Spiels"
value={name}
onChange={(e) => setName(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
<textarea
placeholder="Kurzbeschreibung (Genre, Welt, ...)"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
rows={3}
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={creating}
className="self-start rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50"
>
{creating ? "Erstelle…" : "Erstellen"}
</button>
</form>
)}
</div>
);
}
+3 -1
View File
@@ -3,9 +3,11 @@ import { useState } from "react";
export default function SendMessageForm({
onSend,
disabled,
placeholder = "Was tust du?",
}: {
onSend: (content: string) => Promise<void>;
disabled?: boolean;
placeholder?: string;
}) {
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
@@ -28,7 +30,7 @@ export default function SendMessageForm({
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={disabled || sending}
placeholder="Was tust du?"
placeholder={placeholder}
rows={2}
className="flex-1 resize-none rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400 disabled:opacity-50"
/>