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:
@@ -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,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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user