From b23ab61841417e01cef9e1aa8f8954515c60a707 Mon Sep 17 00:00:00 2001 From: itsamejms Date: Sun, 28 Jun 2026 23:03:10 +0100 Subject: [PATCH] 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
when parsing fails - Added 'IMPORTANT: Return ONLY raw JSON' instruction to all prompts - Full production build passing --- src/components/EncounterBuilder.tsx | 18 +++--- src/components/ItemForge.tsx | 66 +++++++++++++++------ src/components/NpcGenerator.tsx | 22 +++---- src/components/QuestDesigner.tsx | 15 +++-- src/components/WorldBuilder.tsx | 43 +++++++------- src/lib/llm-parse.ts | 91 +++++++++++++++++++++++++++++ 6 files changed, 192 insertions(+), 63 deletions(-) create mode 100644 src/lib/llm-parse.ts diff --git a/src/components/EncounterBuilder.tsx b/src/components/EncounterBuilder.tsx index 269bc05..8a723e2 100644 --- a/src/components/EncounterBuilder.tsx +++ b/src/components/EncounterBuilder.tsx @@ -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(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:
Monsters
    - {encounter.monsters?.map((m, i) =>
  • {m}
  • )} + {ensureArray(encounter.monsters).map((m, i) =>
  • {flattenValue(m)}
  • )}
diff --git a/src/components/ItemForge.tsx b/src/components/ItemForge.tsx index 8fda2e3..d5ce511 100644 --- a/src/components/ItemForge.tsx +++ b/src/components/ItemForge.tsx @@ -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; lore: string; } @@ -18,6 +19,7 @@ export function ItemForge() { const [itemType, setItemType] = useState("Wondrous Item"); const [prompt, setPrompt] = useState(""); const [item, setItem] = useState(null); + const [rawResponse, setRawResponse] = useState(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("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(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 (
@@ -123,32 +135,48 @@ Provide the response as a JSON object with exactly these keys:

- {item.name} + {item.name || "Unknown Item"}

- {item.rarity} + {item.rarity || rarity} - {item.type} + {item.type || itemType}
Description -

{item.description}

+

+ {flattenValue(item.description)} +

Mechanics -

{item.mechanical}

+

{mechanicalText}

Lore -

{item.lore}

+

+ {flattenValue(item.lore)} +

)} + + {/* Show raw response if JSON parsing failed */} + {hasParseError && ( +
+ + Raw LLM response (click to expand) + +
+            {rawResponse}
+          
+
+ )}
); } \ No newline at end of file diff --git a/src/components/NpcGenerator.tsx b/src/components/NpcGenerator.tsx index 7c2001b..bbcd5b7 100644 --- a/src/components/NpcGenerator.tsx +++ b/src/components/NpcGenerator.tsx @@ -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("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(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:
Personality:
- {npc.personality?.map((p, i) => ( + {ensureArray(npc.personality).map((p, i) => ( - {p} + {flattenValue(p)} ))}
@@ -130,7 +132,7 @@ Provide the response as a JSON object with exactly these keys:
Goals:
    - {npc.goals?.map((g, i) =>
  • {g}
  • )} + {ensureArray(npc.goals).map((g, i) =>
  • {flattenValue(g)}
  • )}
diff --git a/src/components/QuestDesigner.tsx b/src/components/QuestDesigner.tsx index bbe1d55..57bafa5 100644 --- a/src/components/QuestDesigner.tsx +++ b/src/components/QuestDesigner.tsx @@ -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(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)); diff --git a/src/components/WorldBuilder.tsx b/src/components/WorldBuilder.tsx index bc89595..f5d11af 100644 --- a/src/components/WorldBuilder.tsx +++ b/src/components/WorldBuilder.tsx @@ -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(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
- {world.regions.map((r, i) => ( + {ensureArray(world.regions).map((r, i) => (
- {r} + {flattenValue(r)}
))}
@@ -112,14 +115,14 @@ Provide the response as a JSON object with exactly these keys: 🏛 Landmarks
- {world.landmarks.map((l, i) => ( + {ensureArray(world.landmarks).map((l, i) => ( - {l} + {flattenValue(l)} ))}
- )} + )}) {/* Conflicts */} {world.conflicts?.length > 0 && ( @@ -128,14 +131,14 @@ Provide the response as a JSON object with exactly these keys: ⚔ Conflicts
- {world.conflicts.map((c, i) => ( + {ensureArray(world.conflicts).map((c, i) => (
- {c} + {flattenValue(c)}
))}
- )} + )}) {/* Cultures */} {world.cultures?.length > 0 && ( @@ -144,14 +147,14 @@ Provide the response as a JSON object with exactly these keys: 👥 Cultures
- {world.cultures.map((c, i) => ( + {ensureArray(world.cultures).map((c, i) => ( - {c} + {flattenValue(c)} ))}
- )} + )}) )} diff --git a/src/lib/llm-parse.ts b/src/lib/llm-parse.ts new file mode 100644 index 0000000..ce91ea8 --- /dev/null +++ b/src/lib/llm-parse.ts @@ -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(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) + .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 []; +} \ No newline at end of file