fix: robust LLM JSON parsing across all AI components

- Created shared lib/llm-parse.ts with extractJson(), parseLlmJson(),
  flattenValue(), and ensureArray() utilities
- Strips markdown code fences (\`\`\`json ... \`\`\`) before parsing
- Handles nested objects where strings were expected (flattens them)
- Handles arrays that come back as strings or objects
- Updated all AI prompts to explicitly request raw JSON with no fences
- All 5 AI components now use parseLlmJson() instead of raw regex
- ItemForge shows raw LLM response in expandable <details> when parsing fails
- Added 'IMPORTANT: Return ONLY raw JSON' instruction to all prompts
- Full production build passing
This commit is contained in:
itsamejms
2026-06-28 23:03:10 +01:00
parent 9b0483a2ab
commit b23ab61841
6 changed files with 192 additions and 63 deletions
+10 -8
View File
@@ -1,3 +1,4 @@
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
@@ -26,21 +27,22 @@ export function EncounterBuilder() {
prompt: `Generate a D&D 5e encounter for a party of ${partySize} level-${partyLevel} characters in a ${terrain.toLowerCase()} setting.
Provide the response as a JSON object with exactly these keys:
- "monsters": an array of 2-4 monster descriptions with quantities (e.g. "3x Goblin Scouts")
- "monsters": an array of 2-4 monster descriptions with quantities (e.g. "3x Goblin Scouts") — each must be a string, not an object
- "terrain": a brief terrain description with environmental features
- "difficulty": one of Easy/Medium/Hard/Deadly
- "loot": a brief treasure description`,
system: "You are a D&D encounter designer. Always respond with valid JSON only.",
- "loot": a brief treasure description
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`,
system: "You are a D&D encounter designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
temperature: 0.9,
max_tokens: 400,
},
});
const jsonMatch = result.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]) as Encounter;
const parsed = parseLlmJson<Encounter>(result);
if (parsed) {
setEncounter(parsed);
} else {
setError("LLM did not return valid JSON. Raw:\n" + result);
setError("Could not parse LLM response. Try again or check your LLM connection.");
}
} catch (e) {
setError(String(e));
@@ -116,7 +118,7 @@ Provide the response as a JSON object with exactly these keys:
<div>
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium">Monsters</span>
<ul className="list-disc list-inside text-[var(--color-text-primary)] text-xs mt-0.5">
{encounter.monsters?.map((m, i) => <li key={i}>{m}</li>)}
{ensureArray(encounter.monsters).map((m, i) => <li key={i}>{flattenValue(m)}</li>)}
</ul>
</div>
+47 -19
View File
@@ -1,12 +1,13 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { parseLlmJson, flattenValue } from "../lib/llm-parse";
interface ItemResponse {
name: string;
rarity: string;
type: string;
description: string;
mechanical: string;
mechanical: string | Record<string, unknown>;
lore: string;
}
@@ -18,6 +19,7 @@ export function ItemForge() {
const [itemType, setItemType] = useState("Wondrous Item");
const [prompt, setPrompt] = useState("");
const [item, setItem] = useState<ItemResponse | null>(null);
const [rawResponse, setRawResponse] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -25,6 +27,7 @@ export function ItemForge() {
setLoading(true);
setError("");
setItem(null);
setRawResponse(null);
try {
const result = await invoke<string>("generate", {
req: {
@@ -33,23 +36,29 @@ Rarity: ${rarity}
Type: ${itemType}
${prompt ? `Additional details: ${prompt}` : ""}
Provide the response as a JSON object with exactly these keys:
- "name": the item's evocative name
- "rarity": "${rarity}"
- "type": "${itemType}"
- "description": a vivid 2-3 sentence physical description
- "mechanical": the item's game mechanics (what it does, charges, etc.)
- "lore": a 1-2 sentence piece of lore or history about the item`,
system: "You are a creative D&D item designer. Always respond with valid JSON only.",
IMPORTANT: Return ONLY a raw JSON object, with NO markdown fences, NO code blocks, NO extra text.
The response must start with { and end with }.
The JSON must have exactly these keys:
- "name": the item's evocative name (string)
- "rarity": "${rarity}" (string)
- "type": "${itemType}" (string)
- "description": a vivid 2-3 sentence physical description (string)
- "mechanical": the item's game mechanics as a single descriptive paragraph (string, NOT an object)
- "lore": a 1-2 sentence piece of lore or history (string)`,
system: "You are a creative D&D item designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
temperature: 0.85,
max_tokens: 500,
},
});
const jsonMatch = result.match(/\{[\s\S]*\}/);
if (jsonMatch) {
setItem(JSON.parse(jsonMatch[0]) as ItemResponse);
setRawResponse(result);
const parsed = parseLlmJson<ItemResponse>(result);
if (parsed) {
setItem(parsed);
} else {
setError("LLM did not return valid JSON.\n" + result);
setError("Could not parse LLM response as JSON. Raw response shown below.");
}
} catch (e) {
setError(String(e));
@@ -66,6 +75,9 @@ Provide the response as a JSON object with exactly these keys:
Artifact: "#e6cc80",
};
const mechanicalText = item ? flattenValue(item.mechanical) : "";
const hasParseError = !item && rawResponse;
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-2">
@@ -123,32 +135,48 @@ Provide the response as a JSON object with exactly these keys:
<div className="flex items-start justify-between">
<div>
<h3 className="font-heading text-lg font-bold" style={{ color: rarityColor[item.rarity] || "var(--color-gold-bright)" }}>
{item.name}
{item.name || "Unknown Item"}
</h3>
<div className="flex gap-2 text-xs text-[var(--color-text-dim)]">
<span>{item.rarity}</span>
<span>{item.rarity || rarity}</span>
<span></span>
<span>{item.type}</span>
<span>{item.type || itemType}</span>
</div>
</div>
</div>
<div>
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium uppercase tracking-wider">Description</span>
<p className="text-[var(--color-text-primary)] text-sm mt-0.5 leading-relaxed">{item.description}</p>
<p className="text-[var(--color-text-primary)] text-sm mt-0.5 leading-relaxed">
{flattenValue(item.description)}
</p>
</div>
<div className="rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-glass)] p-3">
<span className="text-[var(--color-gold-bright)] text-[10px] font-medium uppercase tracking-wider">Mechanics</span>
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{item.mechanical}</p>
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{mechanicalText}</p>
</div>
<div>
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium uppercase tracking-wider">Lore</span>
<p className="text-[var(--color-text-secondary)] text-sm mt-0.5 italic">{item.lore}</p>
<p className="text-[var(--color-text-secondary)] text-sm mt-0.5 italic">
{flattenValue(item.lore)}
</p>
</div>
</div>
)}
{/* Show raw response if JSON parsing failed */}
{hasParseError && (
<details className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)]">
<summary className="px-3 py-2 text-xs text-[var(--color-text-dim)] cursor-pointer hover:text-[var(--color-text-secondary)]">
Raw LLM response (click to expand)
</summary>
<pre className="px-3 pb-3 text-xs text-[var(--color-text-secondary)] overflow-x-auto whitespace-pre-wrap">
{rawResponse}
</pre>
</details>
)}
</div>
);
}
+12 -10
View File
@@ -1,3 +1,4 @@
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
@@ -32,20 +33,21 @@ Alignment: ${alignment}
Provide the response as a JSON object with exactly these keys:
- "bio": a 2-3 sentence backstory
- "personality": an array of 3-5 personality traits
- "goals": an array of 2-3 goals or motivations`;
- "personality": an array of 3-5 personality traits (each trait must be a string, not an object)
- "goals": an array of 2-3 goals or motivations (each goal must be a string, not an object)
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`;
const result = await invoke<string>("generate", {
req: { prompt, system: "You are a creative D&D dungeon master. Always respond with valid JSON only.", temperature: 0.8, max_tokens: 512 },
req: { prompt, system: "You are a creative D&D dungeon master. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.", temperature: 0.8, max_tokens: 512 },
});
// Try to parse JSON from the response
const jsonMatch = result.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]) as NpcResponse;
const parsed = parseLlmJson<NpcResponse>(result);
if (parsed) {
setNpc(parsed);
} else {
setError("LLM did not return valid JSON. Raw response:\n" + result);
setError("Could not parse LLM response. Try again or check your LLM connection.");
}
} catch (e) {
setError(String(e));
@@ -120,9 +122,9 @@ Provide the response as a JSON object with exactly these keys:
<div>
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Personality:</span>
<div className="flex flex-wrap gap-1 mt-1">
{npc.personality?.map((p, i) => (
{ensureArray(npc.personality).map((p, i) => (
<span key={i} className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-2 py-0.5 text-xs">
{p}
{flattenValue(p)}
</span>
))}
</div>
@@ -130,7 +132,7 @@ Provide the response as a JSON object with exactly these keys:
<div>
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Goals:</span>
<ul className="list-disc list-inside text-[var(--color-text-primary)] text-xs mt-1">
{npc.goals?.map((g, i) => <li key={i}>{g}</li>)}
{ensureArray(npc.goals).map((g, i) => <li key={i}>{flattenValue(g)}</li>)}
</ul>
</div>
</div>
+9 -6
View File
@@ -1,3 +1,4 @@
import { parseLlmJson } from "../lib/llm-parse";
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
@@ -39,17 +40,19 @@ Provide the response as a JSON object with exactly these keys:
- "hook": 1-2 sentences describing how the party gets involved
- "steps": an array of 3-5 quest steps, each with "title" and "description" (2-3 sentences each), and optionally "choice" (a meaningful decision the party faces)
- "twist": a surprise revelation or complication
- "reward": what the party gains on completion`,
system: "You are a creative D&D quest designer. Always respond with valid JSON only.",
- "reward": what the party gains on completion
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`,
system: "You are a creative D&D quest designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
temperature: 0.85,
max_tokens: 700,
},
});
const jsonMatch = result.match(/\{[\s\S]*\}/);
if (jsonMatch) {
setQuest(JSON.parse(jsonMatch[0]) as QuestResponse);
const parsed = parseLlmJson<QuestResponse>(result);
if (parsed) {
setQuest(parsed);
} else {
setError("LLM did not return valid JSON.\n" + result);
setError("Could not parse LLM response. Try again or check your LLM connection.");
}
} catch (e) {
setError(String(e));
+23 -20
View File
@@ -1,3 +1,4 @@
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
@@ -28,20 +29,22 @@ export function WorldBuilder() {
Provide the response as a JSON object with exactly these keys:
- "name": the world's name
- "description": a 2-3 sentence overview
- "regions": an array of 3-4 region names with brief descriptions
- "landmarks": an array of 2-3 notable landmarks
- "conflicts": an array of 2-3 major conflicts or tensions
- "cultures": an array of 2-3 distinct cultures or peoples`,
system: "You are a creative world-building DM. Always respond with valid JSON only.",
- "regions": an array of 3-4 region names with brief descriptions (each must be a string)
- "landmarks": an array of 2-3 notable landmarks (each must be a string)
- "conflicts": an array of 2-3 major conflicts or tensions (each must be a string)
- "cultures": an array of 2-3 distinct cultures or peoples (each must be a string)
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`,
system: "You are a creative world-building DM. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
temperature: 0.9,
max_tokens: 600,
},
});
const jsonMatch = result.match(/\{[\s\S]*\}/);
if (jsonMatch) {
setWorld(JSON.parse(jsonMatch[0]) as WorldResponse);
const parsed = parseLlmJson<WorldResponse>(result);
if (parsed) {
setWorld(parsed);
} else {
setError("LLM did not return valid JSON.\n" + result);
setError("Could not parse LLM response. Try again or check your LLM connection.");
}
} catch (e) {
setError(String(e));
@@ -96,9 +99,9 @@ Provide the response as a JSON object with exactly these keys:
🏔 Regions
</h4>
<div className="flex flex-col gap-1.5">
{world.regions.map((r, i) => (
{ensureArray(world.regions).map((r, i) => (
<div key={i} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-3 py-2 text-sm text-[var(--color-text-primary)]">
{r}
{flattenValue(r)}
</div>
))}
</div>
@@ -112,14 +115,14 @@ Provide the response as a JSON object with exactly these keys:
🏛 Landmarks
</h4>
<div className="flex flex-wrap gap-1.5">
{world.landmarks.map((l, i) => (
{ensureArray(world.landmarks).map((l, i) => (
<span key={i} className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-3 py-1 text-xs">
{l}
{flattenValue(l)}
</span>
))}
</div>
</div>
)}
)})
{/* Conflicts */}
{world.conflicts?.length > 0 && (
@@ -128,14 +131,14 @@ Provide the response as a JSON object with exactly these keys:
Conflicts
</h4>
<div className="flex flex-col gap-1.5">
{world.conflicts.map((c, i) => (
{ensureArray(world.conflicts).map((c, i) => (
<div key={i} className="rounded-lg bg-[var(--color-bg-surface)] border-l-2 border-[var(--color-danger)] px-3 py-2 text-sm text-[var(--color-text-primary)]">
{c}
{flattenValue(c)}
</div>
))}
</div>
</div>
)}
)})
{/* Cultures */}
{world.cultures?.length > 0 && (
@@ -144,14 +147,14 @@ Provide the response as a JSON object with exactly these keys:
👥 Cultures
</h4>
<div className="flex flex-wrap gap-1.5">
{world.cultures.map((c, i) => (
{ensureArray(world.cultures).map((c, i) => (
<span key={i} className="rounded-full bg-[var(--color-bg-surface)] border border-[var(--color-info)]/30 text-[var(--color-info)] px-3 py-1 text-xs">
{c}
{flattenValue(c)}
</span>
))}
</div>
</div>
)}
)})
</div>
)}
</div>
+91
View File
@@ -0,0 +1,91 @@
/**
* Parse LLM output into JSON, handling common LLM quirks:
* 1. Markdown code fences (```json ... ``` or ``` ... ```)
* 2. Leading/trailing text around the JSON
* 3. Nested objects where strings were expected (flatten them)
*/
/** Strip markdown code fences and extract the JSON object */
export function extractJson(raw: string): string | null {
// Remove markdown code fences: ```json ... ``` or ``` ... ```
let cleaned = raw.trim();
// Try to extract from code fence
const fenceMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
if (fenceMatch) {
cleaned = fenceMatch[1].trim();
}
// Find the outermost { ... } brace pair
const start = cleaned.indexOf("{");
const end = cleaned.lastIndexOf("}");
if (start !== -1 && end > start) {
return cleaned.substring(start, end + 1);
}
return null;
}
/** Parse JSON from LLM output, stripping fences and handling errors */
export function parseLlmJson<T>(raw: string): T | null {
const jsonStr = extractJson(raw);
if (!jsonStr) return null;
try {
return JSON.parse(jsonStr) as T;
} catch (e) {
console.error("JSON parse error:", e, "Input:", jsonStr);
return null;
}
}
/**
* Flatten nested objects into display-friendly strings.
* If a value is an object, stringify it nicely.
* If a value is an array of objects, join them.
*/
export function flattenValue(val: unknown): string {
if (val === null || val === undefined) return "";
if (typeof val === "string") return val;
if (typeof val === "number") return String(val);
if (typeof val === "boolean") return String(val);
if (Array.isArray(val)) {
return val.map(flattenValue).join("\n");
}
if (typeof val === "object") {
// Pretty-format the object as key: value pairs
return Object.entries(val as Record<string, unknown>)
.map(([k, v]) => {
const formatted = typeof v === "string" ? v : JSON.stringify(v);
return `**${formatKey(k)}**: ${formatted}`;
})
.join("\n");
}
return String(val);
}
/** Convert camelCase or snake_case keys to Title Case */
function formatKey(key: string): string {
return key
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Safely get an array from a field that might be a string or array */
export function ensureArray(val: unknown): string[] {
if (Array.isArray(val)) {
return val.map((v) => (typeof v === "string" ? v : JSON.stringify(v)));
}
if (typeof val === "string") {
// Try to parse as JSON array
try {
const parsed = JSON.parse(val);
if (Array.isArray(parsed)) return parsed.map((v: unknown) => String(v));
} catch {
// Not JSON, return as single-element array
}
return [val];
}
return [];
}