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
+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>
);