Add character detail, markdown rendering, wider layout, and LLM provider switch

- Character sheets gain structured combat_stats, abilities (name +
  plain-language explanation), and equipment fields, populated by the
  upsert_character_sheet tool; the detail page renders them as their
  own sections instead of one prose block, with a player-card-style
  attribute grid (full ability names + modifiers).
- Fix double-escaped unicode occasionally left in tool-call JSON
  arguments (a model quirk) and stop the app's own json.dumps calls
  from re-introducing it (ensure_ascii=False).
- Render chat messages as markdown (react-markdown + Tailwind
  typography) instead of raw text, and widen the games/characters/
  chat layout to use more of the screen on desktop.
- Sharpen the DM system prompt: state the ability + DC for each
  offered action option before the player commits, and narrate rolls
  as DC → result → pass/fail before the story consequence; also stop
  re-asking Session-0 questions already answered in the game's
  name/description.
- Make the DM's LLM provider configurable (xai default, lmstudio for
  a local OpenAI-compatible server via host.docker.internal) instead
  of hardcoded to x.ai.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-09-01 15:18:15 +02:00
parent ec53ed3aba
commit 624644619c
26 changed files with 1503 additions and 119 deletions
+1201 -79
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -9,8 +9,10 @@
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/typography": "^0.5.20",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
+8
View File
@@ -1,5 +1,10 @@
import { apiFetch } from "./client";
export interface CharacterAbility {
name: string;
description: string;
}
export interface Character {
id: string;
owner_id: string;
@@ -8,6 +13,9 @@ export interface Character {
char_class: string | null;
level: number;
stats: Record<string, number>;
combat_stats: Record<string, string>;
abilities: CharacterAbility[];
equipment: string[];
description: string;
created_at: string;
updated_at: string;
+3 -2
View File
@@ -1,6 +1,7 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { createGame, Game, GameSetupProposal, runGameSetupChat } from "../api/games";
import MarkdownContent from "./MarkdownContent";
import SendMessageForm from "./SendMessageForm";
interface DisplayTurn {
@@ -94,8 +95,8 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
}
>
{turn.role === "assistant" ? "Dungeon Master" : "Du"}:
</span>{" "}
<span className="whitespace-pre-wrap">{turn.content}</span>
</span>
<MarkdownContent content={turn.content} />
</div>
))}
{loading && <p className="text-sm text-slate-500">Der Dungeon Master überlegt…</p>}
@@ -0,0 +1,9 @@
import ReactMarkdown from "react-markdown";
export default function MarkdownContent({ content }: { content: string }) {
return (
<div className="prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-headings:my-1">
<ReactMarkdown>{content}</ReactMarkdown>
</div>
);
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react";
import { Message } from "../api/games";
import MarkdownContent from "./MarkdownContent";
function senderLabel(message: Message): string {
if (message.sender_type === "dm") return "Dungeon Master";
@@ -36,8 +37,8 @@ export default function TranscriptPane({
}
>
{senderLabel(m)}:
</span>{" "}
<span className="whitespace-pre-wrap">{m.content}</span>
</span>
<MarkdownContent content={m.content} />
</div>
))}
{typing && (
+110 -14
View File
@@ -3,6 +3,30 @@ import { Link, useParams, useSearchParams } from "react-router-dom";
import { Character, getCharacter } from "../api/characters";
const ABILITY_NAMES: Record<string, string> = {
STR: "Stärke",
DEX: "Geschicklichkeit",
CON: "Konstitution",
INT: "Intelligenz",
WIS: "Weisheit",
CHA: "Charisma",
};
const ABILITY_ORDER = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
function orderedStats(stats: Record<string, number>): [string, number][] {
const known = ABILITY_ORDER.filter((key) => key in stats).map(
(key) => [key, stats[key]] as [string, number],
);
const unknown = Object.entries(stats).filter(([key]) => !ABILITY_ORDER.includes(key));
return [...known, ...unknown];
}
function modifier(score: number): string {
const mod = Math.floor((score - 10) / 2);
return mod >= 0 ? `+${mod}` : `${mod}`;
}
export default function CharacterDetail() {
const { characterId } = useParams<{ characterId: string }>();
const [searchParams] = useSearchParams();
@@ -22,7 +46,7 @@ export default function CharacterDetail() {
if (!character) return <p className="p-8 text-slate-400">Charakter nicht gefunden.</p>;
return (
<div className="mx-auto max-w-2xl px-4 py-8">
<div className="mx-auto max-w-6xl px-4 py-8">
{backGameId && (
<Link
to={`/games/${backGameId}`}
@@ -32,24 +56,96 @@ export default function CharacterDetail() {
</Link>
)}
<h1 className="text-2xl font-bold text-amber-400">{character.name}</h1>
<p className="mt-1 text-slate-400">
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
</p>
<div className="rounded-lg border border-amber-500/30 bg-slate-900 p-6">
<h1 className="text-3xl font-bold text-amber-400">{character.name}</h1>
<p className="mt-1 text-slate-400">
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
</p>
</div>
{Object.keys(character.stats).length > 0 && (
<div className="mt-4 grid grid-cols-3 gap-3 sm:grid-cols-6">
{Object.entries(character.stats).map(([stat, value]) => (
<div key={stat} className="rounded bg-slate-800 p-2 text-center">
<div className="text-xs uppercase text-slate-500">{stat}</div>
<div className="text-lg font-semibold text-slate-100">{value}</div>
</div>
))}
<div className="mt-6">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Attribute
</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{orderedStats(character.stats).map(([key, value]) => (
<div
key={key}
className="flex flex-col items-center rounded-lg border border-slate-700 bg-slate-800 py-4"
>
<div className="text-xs font-semibold uppercase tracking-wide text-slate-400">
{ABILITY_NAMES[key] ?? key}
</div>
<div className="mt-1 text-3xl font-bold text-slate-100">{value}</div>
<div className="mt-1 rounded-full bg-slate-700 px-2 py-0.5 text-sm font-semibold text-amber-400">
{modifier(value)}
</div>
</div>
))}
</div>
</div>
)}
<div className="mt-6 whitespace-pre-wrap rounded-lg border border-slate-800 bg-slate-900 p-4 text-slate-300">
{character.description || "Noch keine Beschreibung."}
{Object.keys(character.combat_stats).length > 0 && (
<div className="mt-6">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Kampfwerte
</h2>
<div className="flex flex-wrap gap-3">
{Object.entries(character.combat_stats).map(([key, value]) => (
<div
key={key}
className="rounded-lg border border-slate-700 bg-slate-800 px-4 py-2 text-center"
>
<div className="text-xs font-semibold uppercase tracking-wide text-slate-400">
{key}
</div>
<div className="text-lg font-bold text-slate-100">{value}</div>
</div>
))}
</div>
</div>
)}
{character.abilities.length > 0 && (
<div className="mt-6">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Fähigkeiten &amp; Merkmale
</h2>
<div className="flex flex-col gap-3">
{character.abilities.map((ability, i) => (
<div key={i} className="rounded-lg border border-slate-800 bg-slate-900 p-4">
<h3 className="font-semibold text-amber-400">{ability.name}</h3>
<p className="mt-1 whitespace-pre-wrap text-sm text-slate-300">
{ability.description}
</p>
</div>
))}
</div>
</div>
)}
{character.equipment.length > 0 && (
<div className="mt-6">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Ausrüstung
</h2>
<ul className="list-disc rounded-lg border border-slate-800 bg-slate-900 p-4 pl-8 text-slate-300">
{character.equipment.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
)}
<div className="mt-6">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Hintergrundgeschichte
</h2>
<div className="whitespace-pre-wrap rounded-lg border border-slate-800 bg-slate-900 p-4 text-slate-300">
{character.description || "Noch keine Beschreibung."}
</div>
</div>
</div>
);
+2 -2
View File
@@ -14,7 +14,7 @@ export default function CharactersList() {
}, []);
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<div className="mx-auto max-w-6xl px-4 py-8">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Charaktere</h1>
{loading ? (
<p className="text-slate-400">Lädt…</p>
@@ -23,7 +23,7 @@ export default function CharactersList() {
Noch keine Charaktere — sie entstehen im Laufe eines Spiels.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{characters.map((c) => (
<CharacterCard key={c.id} character={c} />
))}
+1 -1
View File
@@ -14,7 +14,7 @@ export default function ForgotPassword() {
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<div className="mx-auto mt-16 max-w-md px-4">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Passwort vergessen</h1>
{sent ? (
<p className="text-slate-300">
+2 -2
View File
@@ -114,7 +114,7 @@ export default function GameSession() {
if (!game.is_participant) {
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<div className="mx-auto mt-16 max-w-md px-4">
<h1 className="mb-2 text-2xl font-bold text-amber-400">{game.name}</h1>
<p className="mb-6 text-sm text-slate-400">{game.description}</p>
<form onSubmit={handleJoin} className="flex flex-col gap-4">
@@ -152,7 +152,7 @@ export default function GameSession() {
}
return (
<div className="mx-auto flex h-[calc(100vh-56px)] max-w-3xl flex-col px-4 py-4">
<div className="mx-auto flex h-[calc(100vh-56px)] max-w-6xl flex-col px-4 py-4">
<div className="mb-3 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-amber-400">{game.name}</h1>
+2 -2
View File
@@ -25,7 +25,7 @@ export default function GamesList() {
}, []);
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<div className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-amber-400">Spiele</h1>
<div className="flex gap-2">
@@ -45,7 +45,7 @@ export default function GamesList() {
) : games.length === 0 ? (
<p className="text-slate-400">Noch keine Spiele vorhanden.</p>
) : (
<div className="flex flex-col gap-3">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{games.map((game) => (
<GameCard key={game.id} game={game} />
))}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function Login() {
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<div className="mx-auto mt-16 max-w-md px-4">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Anmelden</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
+1 -1
View File
@@ -27,7 +27,7 @@ export default function Register() {
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<div className="mx-auto mt-16 max-w-md px-4">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Registrieren</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
+2 -2
View File
@@ -25,7 +25,7 @@ export default function ResetPassword() {
if (!token) {
return (
<div className="mx-auto mt-16 max-w-sm px-4 text-slate-300">
<div className="mx-auto mt-16 max-w-md px-4 text-slate-300">
Kein gültiger Reset-Link.{" "}
<Link to="/forgot-password" className="text-amber-400">
Neu anfordern
@@ -35,7 +35,7 @@ export default function ResetPassword() {
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<div className="mx-auto mt-16 max-w-md px-4">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Neues Passwort setzen</h1>
{done ? (
<p className="text-slate-300">Passwort geändert. Du wirst weitergeleitet…</p>
+3 -1
View File
@@ -1,8 +1,10 @@
import typography from "@tailwindcss/typography";
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
plugins: [typography],
};